Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions web-admin/src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <span className="text-sm text-muted-foreground">—</span>
return (
<div className="flex flex-col items-start gap-y-0.5">
Expand Down
21 changes: 19 additions & 2 deletions web-admin/src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="node" />
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"

Expand Down Expand Up @@ -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")
26 changes: 26 additions & 0 deletions web-admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node, "ip" | "ipv4" | "ipv6">): 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 {
Expand Down
Loading