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
This commit is contained in:
VooDisss
2026-03-31 02:58:42 +03:00
parent 27bccb8d6b
commit 984743f3c7
3 changed files with 120 additions and 5 deletions

View File

@@ -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<typeof resolveNetworkAddresses>
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) {

View File

@@ -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<typeof os.networkInterfaces>[string],
})) as typeof os.networkInterfaces
try {
callback()
} finally {
os.networkInterfaces = original
}
}

View File

@@ -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<NetworkAddress, "ip" | "scope">): 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(".")