Skip to content

Commit f3c54df

Browse files
VooDisssshantur
andauthored
fix(server): show sane remote URLs for 0.0.0.0 binds (#262)
Closes #261 ## Summary - improve startup remote URL selection when the server binds to `0.0.0.0` - print additional reachable remote URLs instead of advertising only the first external address - add targeted tests for address ordering and advertisability behavior ## Problem When CodeNomad was started with `--host 0.0.0.0`, the CLI chose the first external IPv4 address it discovered and displayed only that one as the remote URL. On Windows machines with WSL, Hyper-V, Docker, or other virtual adapters, that often surfaced a virtual `172.x.x.x` address even though a more useful LAN address such as `192.168.x.x` was also reachable and usable from other devices. That made remote access look broken or confusing even though the server itself was accessible. ## What changed - reuse the resolved network-address list for both: - primary remote URL selection - startup logging of additional reachable URLs - choose the primary remote URL from the **advertisable** external addresses instead of any external address - print `Other Accessible URLs` when multiple useful remote URLs are available - avoid hard-coding a preference like `192.168 > 10 > 172` - suppress link-local `169.254.*` addresses from user-facing advertised URLs - add tests covering: - stable ordering across RFC1918 address ranges - link-local addresses being non-advertisable - link-local-first discovery not stealing the primary LAN URL ## Why this approach This keeps address derivation in the network-address resolver layer and limits `index.ts` to startup wiring and presentation. It also fixes the misleading terminal output without redesigning binding behavior, TLS behavior, or the server API contract. ## Validation - `npm run typecheck --workspace @neuralnomads/codenomad` - `npx tsx --test '.\\src\\server\\__tests__\\network-addresses.test.ts'` ## Notes - this change is intentionally focused on selection and presentation of reachable addresses - it does not attempt a broader virtual-adapter classification policy beyond suppressing clearly low-value link-local addresses in user-facing output --------- Co-authored-by: Shantur Rathore <i@shantur.com>
1 parent 278b563 commit f3c54df

17 files changed

Lines changed: 490 additions & 54 deletions

packages/server/src/index.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { launchInBrowser } from "./launcher"
2121
import { resolveUi } from "./ui/remote-ui"
2222
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
2323
import { resolveHttpsOptions } from "./server/tls"
24-
import { resolveNetworkAddresses } from "./server/network-addresses"
24+
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
2525
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
2626
import { SpeechService } from "./speech/service"
2727

@@ -451,18 +451,22 @@ async function main() {
451451
// which can lead clients to talk to the wrong process.
452452
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
453453
let remoteUrl: string | undefined
454+
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
454455
if (remoteStart) {
455456
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
456457
let remoteHost = options.host
457458
if (wantsAll) {
458459
if (options.host === "0.0.0.0") {
459-
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
460-
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
460+
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
461+
remoteAddresses = resolved.userVisible
462+
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
461463
}
462464
} else {
463465
remoteHost = "localhost"
464466
}
465-
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
467+
if (!remoteUrl) {
468+
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
469+
}
466470
}
467471

468472
serverMeta.localUrl = localUrl
@@ -473,14 +477,26 @@ async function main() {
473477
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
474478

475479
if (serverMeta.remotePort && remoteUrl) {
476-
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
480+
serverMeta.addresses = remoteAddresses.length
481+
? remoteAddresses
482+
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
477483
} else {
478484
serverMeta.addresses = []
479485
}
480486

481487
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
482488
if (serverMeta.remoteUrl) {
483489
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
490+
const additionalRemoteUrls = serverMeta.addresses
491+
.map((addr) => addr.remoteUrl)
492+
.filter((url) => url !== serverMeta.remoteUrl)
493+
494+
if (additionalRemoteUrls.length > 0) {
495+
console.log("Other Accessible URLs:")
496+
for (const url of additionalRemoteUrls) {
497+
console.log(` - ${url}`)
498+
}
499+
}
484500
}
485501

486502
if (options.launch) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import assert from "node:assert/strict"
2+
import os from "node:os"
3+
import { describe, it } from "node:test"
4+
5+
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
6+
7+
describe("resolveNetworkAddresses", () => {
8+
it("preserves interface order among external addresses", () => {
9+
const addresses = [
10+
{ address: "172.24.0.1", family: "IPv4", internal: false },
11+
{ address: "192.168.1.128", family: "IPv4", internal: false },
12+
{ address: "10.0.0.8", family: 4, internal: false },
13+
{ address: "127.0.0.1", family: "IPv4", internal: true },
14+
{ address: "169.254.10.20", family: "IPv4", internal: false },
15+
]
16+
17+
usingMockedNetworkInterfaces(addresses, () => {
18+
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
19+
20+
assert.deepEqual(
21+
result.map((entry) => entry.ip),
22+
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
23+
)
24+
})
25+
})
26+
})
27+
28+
describe("resolveRemoteAddresses", () => {
29+
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
30+
const addresses = [
31+
{ address: "169.254.10.20", family: "IPv4", internal: false },
32+
{ address: "192.168.1.128", family: "IPv4", internal: false },
33+
{ address: "172.24.0.1", family: "IPv4", internal: false },
34+
]
35+
36+
usingMockedNetworkInterfaces(addresses, () => {
37+
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
38+
39+
assert.deepEqual(
40+
result.userVisible.map((entry) => entry.ip),
41+
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
42+
)
43+
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
44+
})
45+
})
46+
47+
it("prefers private LAN addresses over public addresses", () => {
48+
const addresses = [
49+
{ address: "203.0.113.40", family: "IPv4", internal: false },
50+
{ address: "192.168.1.128", family: "IPv4", internal: false },
51+
{ address: "8.8.8.8", family: "IPv4", internal: false },
52+
]
53+
54+
usingMockedNetworkInterfaces(addresses, () => {
55+
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
56+
57+
assert.deepEqual(
58+
result.userVisible.map((entry) => entry.ip),
59+
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
60+
)
61+
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
62+
})
63+
})
64+
65+
it("uses a public address when no private LAN address is available", () => {
66+
const addresses = [
67+
{ address: "169.254.10.20", family: "IPv4", internal: false },
68+
{ address: "203.0.113.40", family: "IPv4", internal: false },
69+
]
70+
71+
usingMockedNetworkInterfaces(addresses, () => {
72+
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
73+
74+
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
75+
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
76+
})
77+
})
78+
})
79+
80+
function usingMockedNetworkInterfaces(
81+
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
82+
callback: () => void,
83+
) {
84+
const original = os.networkInterfaces
85+
os.networkInterfaces = (() => ({
86+
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
87+
})) as typeof os.networkInterfaces
88+
89+
try {
90+
callback()
91+
} finally {
92+
os.networkInterfaces = original
93+
}
94+
}

packages/server/src/server/network-addresses.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import os from "os"
22
import type { NetworkAddress } from "../api-types"
33

4+
export interface ResolvedRemoteAddresses {
5+
all: NetworkAddress[]
6+
userVisible: NetworkAddress[]
7+
primaryRemoteUrl?: string
8+
}
9+
410
export function resolveNetworkAddresses(args: {
511
host: string
612
protocol: "http" | "https"
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
5864
return results.sort((a, b) => {
5965
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
6066
if (scopeDelta !== 0) return scopeDelta
61-
return a.ip.localeCompare(b.ip)
67+
68+
return 0
6269
})
6370
}
6471

72+
export function resolveRemoteAddresses(args: {
73+
host: string
74+
protocol: "http" | "https"
75+
port: number
76+
}): ResolvedRemoteAddresses {
77+
const all = resolveNetworkAddresses(args)
78+
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
79+
return {
80+
all,
81+
userVisible,
82+
primaryRemoteUrl: userVisible[0]?.remoteUrl,
83+
}
84+
}
85+
86+
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
87+
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
88+
}
89+
90+
function getUserVisiblePriority(ip: string): number {
91+
if (isPrivateIPv4(ip)) return 0
92+
if (isLinkLocalIPv4(ip)) return 2
93+
return 1
94+
}
95+
96+
function isLinkLocalIPv4(ip: string): boolean {
97+
const octets = parseIPv4(ip)
98+
if (!octets) return false
99+
const [first, second] = octets
100+
return first === 169 && second === 254
101+
}
102+
103+
function isPrivateIPv4(ip: string): boolean {
104+
const octets = parseIPv4(ip)
105+
if (!octets) return false
106+
const [first, second] = octets
107+
108+
if (first === 10) return true
109+
if (first === 192 && second === 168) return true
110+
return first === 172 && second >= 16 && second <= 31
111+
}
112+
113+
function parseIPv4(value: string): number[] | null {
114+
if (!isIPv4Address(value)) return null
115+
return value.split(".").map((part) => Number(part))
116+
}
117+
65118
function isIPv4Address(value: string | undefined): value is string {
66119
if (!value) return false
67120
const parts = value.split(".")

packages/server/src/server/routes/meta.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FastifyInstance } from "fastify"
22
import { ServerMeta } from "../../api-types"
3-
import { resolveNetworkAddresses } from "../network-addresses"
3+
44

55
interface RouteDeps {
66
serverMeta: ServerMeta
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
1313
function buildMetaResponse(meta: ServerMeta): ServerMeta {
1414
const localPort = resolveLocalPort(meta)
1515
const remote = resolveRemote(meta)
16-
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
1716

1817
return {
1918
...meta,
2019
localPort,
2120
remotePort: remote?.port,
2221
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
23-
addresses,
2422
}
2523
}
2624

0 commit comments

Comments
 (0)