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 {