Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
30 changes: 12 additions & 18 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, isLoopbackHost, isWildcardHost } 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 @@ -310,8 +311,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 +446,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 +466,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 +549,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 +577,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",
)
})
})
36 changes: 36 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,42 @@ 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("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
29 changes: 29 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,29 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { formatHostForUrl, isLoopbackHost, isWildcardHost } 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("::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("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]")
})
})
50 changes: 50 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,50 @@
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")
}))
})

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
13 changes: 10 additions & 3 deletions packages/server/src/server/listener-base-url.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isIP } from "node:net"
import { formatHostForUrl, isLoopbackHost, isWildcardHost, stripHostBrackets } from "./network-host"

export interface StartedListenerBaseUrlInput {
protocol: "http" | "https"
bindHost: string
Expand All @@ -13,7 +16,11 @@ export interface ResolvePluginBaseUrlInput {
export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string {
const loopbackListener = [input.httpStart, input.httpsStart].find((listener) => listener && acceptsLoopback(listener.bindHost))
if (loopbackListener) {
return `${loopbackListener.protocol}://127.0.0.1:${loopbackListener.port}`
const bindHost = stripHostBrackets(loopbackListener.bindHost)
const loopbackHost = isWildcardHost(bindHost)
? isIP(bindHost) === 6 ? "::1" : "127.0.0.1"
: bindHost
return `${loopbackListener.protocol}://${formatHostForUrl(loopbackHost)}:${loopbackListener.port}`
}

if (input.remoteUrl) {
Expand All @@ -25,9 +32,9 @@ export function resolvePluginBaseUrl(input: ResolvePluginBaseUrlInput): string {
throw new Error("No listeners started")
}

return `${fallbackListener.protocol}://${fallbackListener.bindHost}:${fallbackListener.port}`
return `${fallbackListener.protocol}://${formatHostForUrl(fallbackListener.bindHost)}:${fallbackListener.port}`
}

function acceptsLoopback(bindHost: string): boolean {
return bindHost === "0.0.0.0" || bindHost === "::" || bindHost === "localhost" || bindHost === "::1" || bindHost.startsWith("127.")
return bindHost === "localhost" || isWildcardHost(bindHost) || isLoopbackHost(bindHost)
}
Loading
Loading