diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c20a64b2..97dd9b69 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,7 +21,7 @@ import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" -import { resolveNetworkAddresses } from "./server/network-addresses" +import { isAdvertisableRemoteAddress, resolveNetworkAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" @@ -451,13 +451,14 @@ async function main() { // which can lead clients to talk to the wrong process. const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` let remoteUrl: string | undefined + let remoteAddresses = [] as ReturnType if (remoteStart) { const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) let remoteHost = options.host if (wantsAll) { if (options.host === "0.0.0.0") { - const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) - remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost" + remoteAddresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) + remoteHost = remoteAddresses.find((addr) => isAdvertisableRemoteAddress(addr))?.ip ?? "localhost" } } else { remoteHost = "localhost" @@ -473,7 +474,9 @@ async function main() { serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" if (serverMeta.remotePort && remoteUrl) { - serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) + serverMeta.addresses = remoteAddresses.length + ? remoteAddresses + : resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) } else { serverMeta.addresses = [] } @@ -481,6 +484,17 @@ async function main() { console.log(`Local Connection URL : ${serverMeta.localUrl}`) if (serverMeta.remoteUrl) { console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) + const additionalRemoteUrls = serverMeta.addresses + .filter((addr) => isAdvertisableRemoteAddress(addr)) + .map((addr) => addr.remoteUrl) + .filter((url) => url !== serverMeta.remoteUrl) + + if (additionalRemoteUrls.length > 0) { + console.log("Other Accessible URLs:") + for (const url of additionalRemoteUrls) { + console.log(` - ${url}`) + } + } } if (options.launch) { diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts new file mode 100644 index 00000000..fec66021 --- /dev/null +++ b/packages/server/src/server/__tests__/network-addresses.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict" +import os from "node:os" +import { describe, it } from "node:test" + +import { isAdvertisableRemoteAddress, resolveNetworkAddresses } from "../network-addresses" + +describe("resolveNetworkAddresses", () => { + it("keeps RFC1918 addresses grouped without preferring one private range over another", () => { + const addresses = [ + { address: "172.24.0.1", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + { address: "10.0.0.8", family: 4, internal: false }, + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "169.254.10.20", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + + assert.deepEqual( + result.map((entry) => entry.ip), + ["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"], + ) + }) + }) + + it("marks link-local addresses as non-advertisable for terminal output", () => { + assert.equal(isAdvertisableRemoteAddress({ ip: "169.254.10.20", scope: "external" }), false) + assert.equal(isAdvertisableRemoteAddress({ ip: "192.168.1.128", scope: "external" }), true) + assert.equal(isAdvertisableRemoteAddress({ ip: "127.0.0.1", scope: "loopback" }), false) + }) + + it("keeps a usable LAN address advertisable when a link-local address is discovered first", () => { + const addresses = [ + { address: "169.254.10.20", family: "IPv4", internal: false }, + { address: "192.168.1.128", family: "IPv4", internal: false }, + ] + + usingMockedNetworkInterfaces(addresses, () => { + const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 }) + const primaryAdvertisable = result.find((entry) => isAdvertisableRemoteAddress(entry)) + + assert.equal(primaryAdvertisable?.ip, "192.168.1.128") + }) + }) +}) + +function usingMockedNetworkInterfaces( + addresses: Array<{ address: string; family: string | number; internal: boolean }>, + callback: () => void, +) { + const original = os.networkInterfaces + os.networkInterfaces = (() => ({ + ethernet0: addresses as unknown as ReturnType[string], + })) as typeof os.networkInterfaces + + try { + callback() + } finally { + os.networkInterfaces = original + } +} diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts index ffedd821..8580ac2f 100644 --- a/packages/server/src/server/network-addresses.ts +++ b/packages/server/src/server/network-addresses.ts @@ -58,10 +58,49 @@ export function resolveNetworkAddresses(args: { return results.sort((a, b) => { const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] if (scopeDelta !== 0) return scopeDelta - return a.ip.localeCompare(b.ip) + + const addressDelta = compareAddressPriority(a.ip, b.ip) + if (addressDelta !== 0) return addressDelta + + return 0 }) } +export function isAdvertisableRemoteAddress(address: Pick): boolean { + if (address.scope !== "external") return false + return !isLinkLocalIPv4(address.ip) +} + +function compareAddressPriority(left: string, right: string): number { + return getAddressPriority(left) - getAddressPriority(right) +} + +function getAddressPriority(ip: string): number { + const octets = parseIPv4(ip) + if (!octets) return 100 + + const [first, second] = octets + + if (isLinkLocalIPv4(ip)) return 90 + if (first === 172 && second >= 16 && second <= 31) return 10 + if (first === 10) return 10 + if (first === 192 && second === 168) return 10 + + return 50 +} + +function isLinkLocalIPv4(ip: string): boolean { + const octets = parseIPv4(ip) + if (!octets) return false + const [first, second] = octets + return first === 169 && second === 254 +} + +function parseIPv4(value: string): number[] | null { + if (!isIPv4Address(value)) return null + return value.split(".").map((part) => Number(part)) +} + function isIPv4Address(value: string | undefined): value is string { if (!value) return false const parts = value.split(".")