Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ jobs:
run: >-
node --import tsx --test
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/settings/info-settings-diagnostics.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/clipboard.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/server-meta.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
Expand Down
11 changes: 10 additions & 1 deletion packages/server/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"
import { once } from "node:events"
import { describe, it } from "node:test"

import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index"
import { installShutdownSignalHandlers, installShutdownStdinHandler, resolveHost, STDIN_SHUTDOWN_COMMAND } from "./index"
import { createServerShutdownHandler } from "./shutdown"

describe("CLI shutdown signal registration", () => {
Expand Down Expand Up @@ -71,3 +71,12 @@ describe("CLI shutdown signal registration", () => {
assert.equal(code, 0)
})
})

describe("CLI host normalization", () => {
it("normalizes mapped and internationalized hosts and rejects IPv6 zones", () => {
assert.equal(resolveHost("::ffff:0:0"), "0.0.0.0")
assert.equal(resolveHost("::ffff:7f00:1"), "127.0.0.1")
assert.equal(resolveHost("münchen.local"), "xn--mnchen-3ya.local")
assert.throws(() => resolveHost("fe80::1%12"), /IPv6 zone identifiers/)
})
})
41 changes: 18 additions & 23 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { resolvePluginBaseUrl } from "./server/listener-base-url"
import { formatHostForUrl, hasIPv6Zone, isLoopbackHost, isWildcardHost, normalizeNetworkHost } from "./server/network-host"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager"
Expand Down Expand Up @@ -263,19 +264,20 @@ function parsePort(input: string): number {
return value
}

function resolveHost(input: string | undefined): string {
export function resolveHost(input: string | undefined): string {
const trimmed = input?.trim()
if (!trimmed) return DEFAULT_HOST

if (trimmed === "0.0.0.0") {
return "0.0.0.0"
if (hasIPv6Zone(trimmed)) {
throw new InvalidArgumentError("IPv6 zone identifiers are not supported in --host")
}

if (trimmed === "localhost") {
const normalized = normalizeNetworkHost(trimmed)
if (normalized === "localhost") {
return DEFAULT_HOST
}

return trimmed
return normalized
}

export function programHasArg(argv: string[], flag: string): boolean {
Expand Down Expand Up @@ -310,8 +312,6 @@ async function main() {

const eventBus = new EventBus(eventLogger)

const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")

const configLocation = resolveConfigLocation(options.configPath)
const configDir = configLocation.baseDir

Expand Down Expand Up @@ -447,8 +447,6 @@ async function main() {
})
: null

const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)

const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
Expand All @@ -469,10 +467,9 @@ async function main() {
const httpBindPort = httpPortExplicit ? options.httpPort : 0

// Listener binding rules:
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
// - Remote access disabled: both listen on loopback.
// - HTTP-only mode: respect --host (used for dev/testing).
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
// - HTTP listens on loopback when HTTPS is also enabled.
// - HTTPS and HTTP-only modes respect --host.
const httpsBindHost = options.host
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"

const servers: Array<ReturnType<typeof createHttpServer>> = []
Expand Down Expand Up @@ -553,19 +550,17 @@ async function main() {
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 resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteAddresses = resolved.userVisible
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
}
} else {
if (isWildcardHost(options.host)) {
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteAddresses = resolved.userVisible
const loopbackHost = options.host === "0.0.0.0" ? "127.0.0.1" : "::1"
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://${formatHostForUrl(loopbackHost)}:${remoteStart.port}`
} else if (options.host === "127.0.0.1") {
remoteHost = "localhost"
}
if (!remoteUrl) {
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
remoteUrl = `${remoteProtocol}://${formatHostForUrl(remoteHost)}:${remoteStart.port}`
}
}

Expand All @@ -583,7 +578,7 @@ async function main() {
serverMeta.remoteUrl = remoteUrl
serverMeta.remotePort = remoteStart?.port
serverMeta.host = options.host
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
serverMeta.listeningMode = isWildcardHost(options.host) || !isLoopbackHost(options.host) ? "all" : "local"

if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = remoteAddresses.length
Expand Down
29 changes: 29 additions & 0 deletions packages/server/src/server/__tests__/listener-base-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,33 @@ describe("resolvePluginBaseUrl", () => {
"http://127.0.0.1:9899",
)
})

it("uses the exact concrete loopback bind host", () => {
assert.equal(
resolvePluginBaseUrl({
httpsStart: { protocol: "https", bindHost: "127.0.0.2", port: 9898 },
remoteUrl: "https://127.0.0.2:9898",
}),
"https://127.0.0.2:9898",
)
})

it("uses bracketed IPv6 loopback for IPv6 wildcard listeners", () => {
assert.equal(
resolvePluginBaseUrl({
httpsStart: { protocol: "https", bindHost: "0:0:0:0:0:0:0:0", port: 9898 },
remoteUrl: "https://[2001:db8::20]:9898",
}),
"https://[::1]:9898",
)
})

it("formats concrete IPv6 fallback listeners", () => {
assert.equal(
resolvePluginBaseUrl({
httpsStart: { protocol: "https", bindHost: "2001:db8::20", port: 9898 },
}),
"https://[2001:db8::20]:9898",
)
})
})
47 changes: 47 additions & 0 deletions packages/server/src/server/__tests__/network-addresses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,53 @@ describe("resolveNetworkAddresses", () => {
)
})
})

it("advertises only a concrete IPv4 bind host", () => {
const result = resolveNetworkAddresses({ host: "127.0.0.2", protocol: "https", port: 9898 })

assert.deepEqual(result, [
{ ip: "127.0.0.2", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.2:9898" },
])
})

it("formats concrete IPv6 bind hosts as valid URLs", () => {
const result = resolveNetworkAddresses({ host: "::1", protocol: "https", port: 9898 })

assert.deepEqual(result, [
{ ip: "::1", family: "ipv6", scope: "loopback", remoteUrl: "https://[::1]:9898" },
])
})

it("reports IPv4-mapped wildcard and loopback hosts as IPv4", () => {
usingMockedNetworkInterfaces([{ address: "192.168.1.20", family: "IPv4", internal: false }], () => {
assert.deepEqual(resolveNetworkAddresses({ host: "::ffff:0:0", protocol: "https", port: 9898 }), [
{ ip: "192.168.1.20", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.20:9898" },
])
assert.deepEqual(resolveNetworkAddresses({ host: "::ffff:7f00:1", protocol: "https", port: 9898 }), [
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
])
})
})

it("enumerates dual-stack interfaces and excludes unusable IPv6 link-local addresses", () => {
const addresses = [
{ address: "2001:db8::20", family: "IPv6", internal: false },
{ address: "fe80::20", family: "IPv6", internal: false },
{ address: "::1", family: 6, internal: true },
{ address: "192.168.1.20", family: "IPv4", internal: false },
]

usingMockedNetworkInterfaces(addresses, () => {
for (const host of ["::", "0:0:0:0:0:0:0:0"]) {
const result = resolveNetworkAddresses({ host, protocol: "https", port: 9898 })
assert.deepEqual(result, [
{ ip: "2001:db8::20", family: "ipv6", scope: "external", remoteUrl: "https://[2001:db8::20]:9898" },
{ ip: "192.168.1.20", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.20:9898" },
{ ip: "::1", family: "ipv6", scope: "loopback", remoteUrl: "https://[::1]:9898" },
])
}
})
})
})

describe("resolveRemoteAddresses", () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/server/src/server/__tests__/network-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { formatHostForUrl, isLoopbackHost, isWildcardHost, normalizeNetworkHost } from "../network-host"

describe("network host helpers", () => {
it("recognizes IPv4 and IPv6 wildcard forms", () => {
assert.equal(isWildcardHost("0.0.0.0"), true)
assert.equal(isWildcardHost("::"), true)
assert.equal(isWildcardHost("0:0:0:0:0:0:0:0"), true)
assert.equal(isWildcardHost("::ffff:0:0"), true)
assert.equal(isWildcardHost("::1"), false)
})

it("recognizes concrete IPv4 and IPv6 loopback forms", () => {
assert.equal(isLoopbackHost("localhost"), true)
assert.equal(isLoopbackHost("127.0.0.2"), true)
assert.equal(isLoopbackHost("::1"), true)
assert.equal(isLoopbackHost("::0001"), true)
assert.equal(isLoopbackHost("0:0:0:0:0:0:0:1"), true)
assert.equal(isLoopbackHost("0:0:0:0:0:0:0:0001"), true)
assert.equal(isLoopbackHost("::ffff:7f00:1"), true)
assert.equal(isLoopbackHost("2001:db8::1"), false)
})

it("brackets IPv6 literals for URLs", () => {
assert.equal(formatHostForUrl("192.168.1.20"), "192.168.1.20")
assert.equal(formatHostForUrl("::1"), "[::1]")
assert.equal(formatHostForUrl("[2001:db8::20]"), "[2001:db8::20]")
})

it("normalizes IPv4-mapped addresses and internationalized DNS names", () => {
assert.equal(normalizeNetworkHost("::ffff:0:0"), "0.0.0.0")
assert.equal(normalizeNetworkHost("::ffff:7f00:1"), "127.0.0.1")
assert.equal(normalizeNetworkHost("::ffff:192.168.1.20"), "192.168.1.20")
assert.equal(normalizeNetworkHost("münchen.local"), "xn--mnchen-3ya.local")
})
})
64 changes: 64 additions & 0 deletions packages/server/src/server/__tests__/tls-ipv6.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict"
import crypto from "node:crypto"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { describe, it } from "node:test"

import type { Logger } from "../../logger"
import { resolveHttpsOptions } from "../tls"

const logger = { info() {}, warn() {}, error() {}, child() { return logger } } as unknown as Logger

describe("generated IPv6 certificates", () => {
it("includes a concrete IPv6 address SAN", () => withTempConfig((configDir) => {
const resolved = resolveHttpsOptions({ enabled: true, configDir, host: "2001:db8::20", logger })
assert.ok(resolved)
const certificate = new crypto.X509Certificate(resolved.httpsOptions.cert)
assert.match(certificate.subjectAltName ?? "", /IP Address:2001:DB8:0:0:0:0:0:20/i)
}))

it("includes IPv6 loopback for a fresh wildcard certificate", () => withTempConfig((configDir) => {
const resolved = resolveHttpsOptions({ enabled: true, configDir, host: "::", logger })
assert.ok(resolved)
const certificate = new crypto.X509Certificate(resolved.httpsOptions.cert)
assert.equal(certificate.checkIP("::1"), "::1")
}))

it("rotates a reused certificate when required host and configured SANs change", () => withTempConfig((configDir) => {
const initial = resolveHttpsOptions({ enabled: true, configDir, host: "127.0.0.1", logger })
assert.ok(initial)
const initialCertificate = new crypto.X509Certificate(initial.httpsOptions.cert)

const rotated = resolveHttpsOptions({ enabled: true, configDir, host: "::", tlsSANs: "diagnostics.local", logger })
assert.ok(rotated)
const rotatedCertificate = new crypto.X509Certificate(rotated.httpsOptions.cert)

assert.notEqual(rotatedCertificate.fingerprint256, initialCertificate.fingerprint256)
assert.equal(rotatedCertificate.checkIP("::1"), "::1")
assert.equal(rotatedCertificate.checkHost("diagnostics.local"), "diagnostics.local")
}))

it("reuses certificates for normalized IDN and scoped IPv6 SAN values", () => withTempConfig((configDir) => {
const first = resolveHttpsOptions({ enabled: true, configDir, host: "münchen.local", tlsSANs: "fe80::1%12", logger })
assert.ok(first)
const firstCertificate = new crypto.X509Certificate(first.httpsOptions.cert)

const reused = resolveHttpsOptions({ enabled: true, configDir, host: "münchen.local", tlsSANs: "fe80::1%12", logger })
assert.ok(reused)
const reusedCertificate = new crypto.X509Certificate(reused.httpsOptions.cert)

assert.equal(reusedCertificate.fingerprint256, firstCertificate.fingerprint256)
assert.equal(reusedCertificate.checkHost("xn--mnchen-3ya.local"), "xn--mnchen-3ya.local")
assert.equal(reusedCertificate.checkIP("fe80::1"), "fe80::1")
}))
})

function withTempConfig(callback: (configDir: string) => void) {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-tls-ipv6-"))
try {
callback(configDir)
} finally {
fs.rmSync(configDir, { recursive: true, force: true })
}
}
12 changes: 6 additions & 6 deletions packages/server/src/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import { connect as connectTcp, isIP, type Socket } from "net"
import path from "path"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch, type Headers } from "undici"
Expand Down Expand Up @@ -47,6 +47,7 @@ import type { SideCarManager } from "../sidecars/manager"
import type { PreviewManager } from "../previews/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
import { createOpenCodeUpdateService } from "../opencode-update/service"
import { formatHostForUrl, isLoopbackHost, isWildcardHost, stripHostBrackets } from "./network-host"

interface HttpServerDeps {
bindHost: string
Expand Down Expand Up @@ -137,8 +138,6 @@ export function createHttpServer(deps: HttpServerDeps) {
})

const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")

const getSelfOrigins = (): Set<string> => {
const origins = new Set<string>()
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
Expand Down Expand Up @@ -179,7 +178,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}

// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
if (isWildcardHost(deps.bindHost) || !isLoopbackHost(deps.bindHost)) {
cb(null, true)
return
}
Expand Down Expand Up @@ -345,7 +344,8 @@ export function createHttpServer(deps: HttpServerDeps) {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
const dualStackWildcard = isWildcardHost(deps.bindHost) && isIP(stripHostBrackets(deps.bindHost)) === 6
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost, ...(dualStackWildcard ? { ipv6Only: false } : {}) })
return { addressInfo, requestedPort }
}

Expand Down Expand Up @@ -380,7 +380,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}
}

const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : formatHostForUrl(deps.bindHost)
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`

deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
Expand Down
Loading
Loading