From ccd1ccd1a31c55197bf59c00032fa60a494575a3 Mon Sep 17 00:00:00 2001 From: CarlJia Date: Sat, 19 Sep 2026 00:15:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(web-admin):=20NAT=20=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E8=BF=9E=E6=8E=A5=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E7=9A=84=E5=85=AC=E7=BD=91=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent 上报的是网卡地址,NAT 机器的网卡上只有内网 IPv4,面板因此只显示 10.x 之类的地址。hub 已记录连接来源地址 ip,此前只在 agent 未上报网卡地址 时使用。 网卡 IPv4 属于内网、CGNAT、回环或链路本地,而来源是公网 IPv4 时,来源地址 排在最前,内网地址随后,两者均可点击复制。hub 与节点同网时来源同样是内网 地址,显示不变。不引入任何外部查询。 移植自 monitor-probe/monitor@fde2981 (#9)。 Co-Authored-By: Claude Sonnet 5 --- web-admin/src/components/Admin.tsx | 7 ++----- web-admin/src/lib/api.test.ts | 18 ++++++++++++++++-- web-admin/src/lib/api.ts | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index ac8354f3..b11ae576 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { - api, changes, GIB, provisioningSite, trafficCorrection, upload, + addresses, api, changes, GIB, provisioningSite, trafficCorrection, upload, type Node, type PingTask, type PluginUsage, } from "@/lib/api" import { bytes, FOREVER, monthUsage, uptime } from "@/lib/format" @@ -53,10 +53,7 @@ function copy(text: string) { // Every address a node has, each click-to-copy: pasting one into an ssh command // is why they are shown. function Addresses({ node }: { node: Node }) { - const reported = [node.ipv4, node.ipv6].filter(Boolean) as string[] - // `ip` is merely where the agent's connection originated: the fallback for an - // agent too old to report its own interfaces. - const list = reported.length ? reported : ([node.ip].filter(Boolean) as string[]) + const list = addresses(node) if (!list.length) return — return (
diff --git a/web-admin/src/lib/api.test.ts b/web-admin/src/lib/api.test.ts index ee372df0..852ecd96 100644 --- a/web-admin/src/lib/api.test.ts +++ b/web-admin/src/lib/api.test.ts @@ -1,6 +1,6 @@ /// import assert from "node:assert/strict" -import { asText, changes, formPayload, GIB, httpErrorText, inputType, moneyCell, normalizeFields, provisioningSite, toastKind, trafficCorrection } from "./api.ts" +import { addresses, asText, changes, formPayload, GIB, httpErrorText, inputType, moneyCell, normalizeFields, provisioningSite, toastKind, trafficCorrection } from "./api.ts" import type { PluginFieldDecl } from "./api.ts" import { dispatchResultText, money } from "./format.ts" @@ -233,4 +233,18 @@ assert.equal(toastKind(undefined), "success") assert.equal(toastKind("warn"), "success") assert.equal(toastKind(""), "success") -console.log("partial edits, traffic corrections, provisioning, page-vocabulary and dispatch-result checks passed") +// NAT: the public address the connection arrived from leads the private interface. +assert.deepEqual(addresses({ ip: "203.0.113.7", ipv4: "10.10.2.250", ipv6: "2001:db8::1" }), ["203.0.113.7", "10.10.2.250", "2001:db8::1"]) +assert.deepEqual(addresses({ ip: "203.0.113.7", ipv4: "100.64.0.9" }), ["203.0.113.7", "100.64.0.9"]) +// Hub on the same network, or on the same machine: the connection says nothing more. +assert.deepEqual(addresses({ ip: "192.168.1.2", ipv4: "192.168.1.5" }), ["192.168.1.5"]) +assert.deepEqual(addresses({ ip: "127.0.0.1", ipv4: "172.16.0.5" }), ["172.16.0.5"]) +// A public interface, or a connection over IPv6, stays as reported. +assert.deepEqual(addresses({ ip: "198.51.100.1", ipv4: "203.0.113.7" }), ["203.0.113.7"]) +assert.deepEqual(addresses({ ip: "2001:db8::2", ipv4: "10.0.0.2", ipv6: "2001:db8::2" }), ["10.0.0.2", "2001:db8::2"]) +assert.deepEqual(addresses({ ip: "203.0.113.7" }), ["203.0.113.7"]) +// Without a recorded connection the list holds only what the agent reported. +assert.deepEqual(addresses({ ipv4: "10.0.0.2" }), ["10.0.0.2"]) +assert.deepEqual(addresses({}), []) + +console.log("partial edits, traffic corrections, provisioning, page-vocabulary, address and dispatch-result checks passed") diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index b64ca48a..71b3964c 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -89,6 +89,28 @@ export function trafficCorrection( ) } +/** Private, carrier-grade NAT, loopback or link-local: unreachable from outside the machine's own network. */ +function isLocalV4(ip: string): boolean { + const [a, b] = ip.split(".").map(Number) + return a === 10 || a === 127 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || + (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) +} + +/** + * The addresses shown for a node. The agent reports its interfaces; `ip` is + * where its connection arrived from, which the hub canonicalizes to dotted form + * for IPv4. Behind NAT the interface holds only a private IPv4 while the + * connection arrives from the public one, so that address leads. `ip` alone is + * also the fallback for an agent too old to report its interfaces. + */ +export function addresses(node: Pick): string[] { + const reported = [node.ipv4, node.ipv6].filter(Boolean) as string[] + const { ip } = node + if (!ip) return reported + if (node.ipv4 && isLocalV4(node.ipv4) && ip.includes(".") && !isLocalV4(ip)) return [ip, ...reported] + return reported.length ? reported : [ip] +} + /** Installation commands require a TLS origin with a domain, never an IP. */ export function provisioningSite(site: string): string { try { From 9e9a47a7a25f9afa9f4567dfba3e02964dabc7f5 Mon Sep 17 00:00:00 2001 From: CarlJia Date: Sat, 19 Sep 2026 07:43:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(review):=20web-admin=20isLocalV4=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=8E=20agent=20is=5Fpublic=20=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E6=8E=92=E9=99=A4=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent/src/collect.rs 的 is_public 已扩展到 192.0.0/24(464XLAT CLAT)、 198.18/15(TUN 代理 fake-IP)、0/8、224+(多播/保留),但 web-admin 的 isLocalV4 只查 192.168/16 等,没同步新排除项。一台 Clash TUN 或 464XLAT 的机器上报的 "公网"地址在面板被当成公网,但 hub 看到的是另一回事,login-throttling 与 地址展示都对不上。 把上述四个新范围加进 isLocalV4,让面板的私网判定与 agent 完全一致。 addresses() 的现存测试用例(覆盖公网/私网/CGNAT/同网等场景)继续通过。 来自 ce-code-review #1(adversarial P1,已通过验证)。 Co-Authored-By: Claude Sonnet 5 --- web-admin/src/lib/api.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 71b3964c..b178e80d 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -91,9 +91,12 @@ export function trafficCorrection( /** Private, carrier-grade NAT, loopback or link-local: unreachable from outside the machine's own network. */ function isLocalV4(ip: string): boolean { - const [a, b] = ip.split(".").map(Number) - return a === 10 || a === 127 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || - (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) + const [a, b, c] = ip.split(".").map(Number) + return a === 0 || a === 10 || a === 127 || a >= 224 || + (a === 172 && b >= 16 && b < 32) || + (a === 192 && (b === 168 || (b === 0 && c === 0))) || + (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || + (a === 198 && (b & 0xfe) === 18) } /** From 68c8ed6d28bc5ea06f44e6209748d8dd76b322c3 Mon Sep 17 00:00:00 2001 From: CarlJia Date: Sat, 19 Sep 2026 07:51:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(web-admin):=20isLocalV4=20=E4=B8=8E=20a?= =?UTF-8?q?gent=20is=5Fpublic=20=E5=90=8C=E6=AD=A5=E6=8E=92=E9=99=A4=20TES?= =?UTF-8?q?T-NETs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游迁移时漏了 TEST-NET-2 (198.51.100/24)、TEST-NET-3 (203.0.113/24) 与 240.0.0.0/4 范围,这些 IANA special-purpose 段 本应被面板当成非公网。补全 isLocalV4 与 agent is_public 的 范围集合。 更新 2 处用 203.0.113.7 当 "公网 connection source" 的 测试断言 —— 现在 203.0.113.7 正确地被判为本地,公网 connection source 的验证改用 8.8.8.8。新加 2 条断言锁定新范围。 来自 ce-code-review #1 的延伸:面板是这次扫描发现的"三处 保持同步"列表里的另一处。 Co-Authored-By: Claude Sonnet 5 --- web-admin/src/lib/api.test.ts | 9 ++++++--- web-admin/src/lib/api.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web-admin/src/lib/api.test.ts b/web-admin/src/lib/api.test.ts index 852ecd96..d23d8450 100644 --- a/web-admin/src/lib/api.test.ts +++ b/web-admin/src/lib/api.test.ts @@ -234,15 +234,18 @@ assert.equal(toastKind("warn"), "success") assert.equal(toastKind(""), "success") // NAT: the public address the connection arrived from leads the private interface. -assert.deepEqual(addresses({ ip: "203.0.113.7", ipv4: "10.10.2.250", ipv6: "2001:db8::1" }), ["203.0.113.7", "10.10.2.250", "2001:db8::1"]) -assert.deepEqual(addresses({ ip: "203.0.113.7", ipv4: "100.64.0.9" }), ["203.0.113.7", "100.64.0.9"]) +assert.deepEqual(addresses({ ip: "8.8.8.8", ipv4: "10.10.2.250", ipv6: "2001:db8::1" }), ["8.8.8.8", "10.10.2.250", "2001:db8::1"]) +assert.deepEqual(addresses({ ip: "8.8.8.8", ipv4: "100.64.0.9" }), ["8.8.8.8", "100.64.0.9"]) // Hub on the same network, or on the same machine: the connection says nothing more. assert.deepEqual(addresses({ ip: "192.168.1.2", ipv4: "192.168.1.5" }), ["192.168.1.5"]) assert.deepEqual(addresses({ ip: "127.0.0.1", ipv4: "172.16.0.5" }), ["172.16.0.5"]) +// IANA special-purpose ranges (TEST-NET-2/3, reserved) are now correctly local; +// 203.0.113.7 / 198.51.100.5 no longer count as a "public" connection source. +assert.deepEqual(addresses({ ip: "203.0.113.7", ipv4: "10.10.2.250" }), ["10.10.2.250"]) +assert.deepEqual(addresses({ ip: "198.51.100.1", ipv4: "198.51.100.5" }), ["198.51.100.5"]) // A public interface, or a connection over IPv6, stays as reported. assert.deepEqual(addresses({ ip: "198.51.100.1", ipv4: "203.0.113.7" }), ["203.0.113.7"]) assert.deepEqual(addresses({ ip: "2001:db8::2", ipv4: "10.0.0.2", ipv6: "2001:db8::2" }), ["10.0.0.2", "2001:db8::2"]) -assert.deepEqual(addresses({ ip: "203.0.113.7" }), ["203.0.113.7"]) // Without a recorded connection the list holds only what the agent reported. assert.deepEqual(addresses({ ipv4: "10.0.0.2" }), ["10.0.0.2"]) assert.deepEqual(addresses({}), []) diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index b178e80d..aa057fc1 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -96,7 +96,8 @@ function isLocalV4(ip: string): boolean { (a === 172 && b >= 16 && b < 32) || (a === 192 && (b === 168 || (b === 0 && c === 0))) || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || - (a === 198 && (b & 0xfe) === 18) + (a === 198 && ((b & 0xfe) === 18 || (b === 51 && c === 100))) || + (a === 203 && b === 0 && c === 113) } /**