From 984743f3c7f61e1c64b76fc3d1820430f05893f4 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Tue, 31 Mar 2026 02:58:42 +0300 Subject: [PATCH] fix(server): show sane remote URLs for 0.0.0.0 binds When CodeNomad was started with --host 0.0.0.0, the CLI picked the first external IPv4 address it discovered and advertised only that one as the remote URL. On Windows machines with WSL, Hyper-V, Docker, or other virtual adapters, this often surfaced a virtual 172.x address even though a normal LAN address such as 192.168.x.x was also reachable and usable from other devices. Tighten remote URL presentation so the startup log reuses the resolved address list, prefers an advertisable external address for the primary Remote Connection URL, and prints additional accessible URLs instead of hiding them. Keep address ordering stable across private RFC1918 ranges instead of hard-coding one subnet family as universally better, while filtering out link-local addresses from the user-facing advertised list to avoid noisy or misleading output. Add targeted tests around address ordering and advertisability so link-local-first interface enumeration does not regress the primary LAN URL selection again. # Conflicts: # packages/server/src/index.ts --- packages/server/src/index.ts | 22 +++++-- .../__tests__/network-addresses.test.ts | 62 +++++++++++++++++++ .../server/src/server/network-addresses.ts | 41 +++++++++++- 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/server/__tests__/network-addresses.test.ts 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(".")