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..d23d8450 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,21 @@ 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: "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"])
+// 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..aa057fc1 100644
--- a/web-admin/src/lib/api.ts
+++ b/web-admin/src/lib/api.ts
@@ -89,6 +89,32 @@ 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, 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 || (b === 51 && c === 100))) ||
+ (a === 203 && b === 0 && c === 113)
+}
+
+/**
+ * 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 {