diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..0bd62a781 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -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 diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts index 2e8f4cf81..984fc827d 100644 --- a/packages/server/src/index.test.ts +++ b/packages/server/src/index.test.ts @@ -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", () => { @@ -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/) + }) +}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3993ed7a6..a35c8561b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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" @@ -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 { @@ -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 @@ -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({ @@ -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> = [] @@ -553,19 +550,17 @@ async function main() { 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 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}` } } @@ -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 diff --git a/packages/server/src/server/__tests__/listener-base-url.test.ts b/packages/server/src/server/__tests__/listener-base-url.test.ts index f742e68e6..191b30cec 100644 --- a/packages/server/src/server/__tests__/listener-base-url.test.ts +++ b/packages/server/src/server/__tests__/listener-base-url.test.ts @@ -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", + ) + }) }) diff --git a/packages/server/src/server/__tests__/network-addresses.test.ts b/packages/server/src/server/__tests__/network-addresses.test.ts index a6d477670..653036713 100644 --- a/packages/server/src/server/__tests__/network-addresses.test.ts +++ b/packages/server/src/server/__tests__/network-addresses.test.ts @@ -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", () => { diff --git a/packages/server/src/server/__tests__/network-host.test.ts b/packages/server/src/server/__tests__/network-host.test.ts new file mode 100644 index 000000000..d606d8fd6 --- /dev/null +++ b/packages/server/src/server/__tests__/network-host.test.ts @@ -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") + }) +}) diff --git a/packages/server/src/server/__tests__/tls-ipv6.test.ts b/packages/server/src/server/__tests__/tls-ipv6.test.ts new file mode 100644 index 000000000..a807e64db --- /dev/null +++ b/packages/server/src/server/__tests__/tls-ipv6.test.ts @@ -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 }) + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 44e855b27..b89b2fd63 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -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" @@ -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 @@ -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 => { const origins = new Set() const candidates: Array = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl] @@ -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 } @@ -345,7 +344,8 @@ export function createHttpServer(deps: HttpServerDeps) { instance: app, start: async (): Promise => { 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 } } @@ -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") diff --git a/packages/server/src/server/listener-base-url.ts b/packages/server/src/server/listener-base-url.ts index 6e68b0a01..4d9d40a15 100644 --- a/packages/server/src/server/listener-base-url.ts +++ b/packages/server/src/server/listener-base-url.ts @@ -1,3 +1,6 @@ +import { isIP } from "node:net" +import { formatHostForUrl, isLoopbackHost, isWildcardHost, normalizeNetworkHost } from "./network-host" + export interface StartedListenerBaseUrlInput { protocol: "http" | "https" bindHost: string @@ -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 = normalizeNetworkHost(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) { @@ -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) } diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts index 8491fc82a..efc1cc030 100644 --- a/packages/server/src/server/network-addresses.ts +++ b/packages/server/src/server/network-addresses.ts @@ -1,5 +1,7 @@ import os from "os" +import { isIP } from "node:net" import type { NetworkAddress } from "../api-types" +import { formatHostForUrl, isLoopbackHost, isWildcardHost, normalizeNetworkHost } from "./network-host" export interface ResolvedRemoteAddresses { all: NetworkAddress[] @@ -12,51 +14,36 @@ export function resolveNetworkAddresses(args: { protocol: "http" | "https" port: number }): NetworkAddress[] { - const { host, protocol, port } = args + const { protocol, port } = args + const host = normalizeNetworkHost(args.host) const interfaces = os.networkInterfaces() const seen = new Set() const results: NetworkAddress[] = [] const addAddress = (ip: string, scope: NetworkAddress["scope"]) => { - if (!ip || ip === "0.0.0.0") return - const key = `ipv4-${ip}` + const normalizedIp = normalizeNetworkHost(ip) + const ipVersion = isIP(normalizedIp) + if (!ipVersion || isWildcardHost(normalizedIp) || (ipVersion === 6 && isLinkLocalIPv6(normalizedIp))) return + const family = ipVersion === 6 ? "ipv6" : "ipv4" + const key = `${family}-${normalizedIp}` if (seen.has(key)) return seen.add(key) - results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` }) + results.push({ ip: normalizedIp, family, scope, remoteUrl: `${protocol}://${formatHostForUrl(normalizedIp)}:${port}` }) } - const normalizeFamily = (value: string | number) => { - if (typeof value === "string") { - const lowered = value.toLowerCase() - if (lowered === "ipv4") { - return "ipv4" as const - } - } - if (value === 4) return "ipv4" as const - return null - } - - if (host === "0.0.0.0") { - // Enumerate system interfaces (IPv4 only) + if (isWildcardHost(host)) { + const wildcardVersion = isIP(host) for (const entries of Object.values(interfaces)) { if (!entries) continue for (const entry of entries) { - const family = normalizeFamily(entry.family) - if (!family) continue - if (!entry.address || entry.address === "0.0.0.0") continue + const entryVersion = isIP(normalizeNetworkHost(entry.address)) + if (entryVersion !== wildcardVersion && !(wildcardVersion === 6 && entryVersion === 4)) continue const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external" addAddress(entry.address, scope) } } - } - - // Always include loopback address - addAddress("127.0.0.1", "loopback") - - // Include explicitly configured host if it was IPv4 - if (isIPv4Address(host) && host !== "0.0.0.0") { - const isLoopback = host.startsWith("127.") - addAddress(host, isLoopback ? "loopback" : "external") + } else if (isIP(host)) { + addAddress(host, isLoopbackHost(host) ? "loopback" : "external") } const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } @@ -111,18 +98,11 @@ function isPrivateIPv4(ip: string): boolean { } function parseIPv4(value: string): number[] | null { - if (!isIPv4Address(value)) return null + if (isIP(value) !== 4) 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(".") - if (parts.length !== 4) return false - return parts.every((part) => { - if (part.length === 0 || part.length > 3) return false - if (!/^[0-9]+$/.test(part)) return false - const num = Number(part) - return Number.isInteger(num) && num >= 0 && num <= 255 - }) +function isLinkLocalIPv6(ip: string): boolean { + const firstSegment = Number.parseInt(ip.split(":", 1)[0], 16) + return Number.isInteger(firstSegment) && firstSegment >= 0xfe80 && firstSegment <= 0xfebf } diff --git a/packages/server/src/server/network-host.ts b/packages/server/src/server/network-host.ts new file mode 100644 index 000000000..2bf360ee7 --- /dev/null +++ b/packages/server/src/server/network-host.ts @@ -0,0 +1,63 @@ +import { isIP } from "node:net" +import { domainToASCII } from "node:url" + +export function stripHostBrackets(host: string): string { + const trimmed = host.trim() + return trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed +} + +export function normalizeNetworkHost(host: string): string { + const value = stripHostBrackets(host).toLowerCase() + if (isIP(value) === 6) { + const canonical = canonicalIPv6(value) + const mapped = canonical?.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) + if (mapped) { + const high = Number.parseInt(mapped[1], 16) + const low = Number.parseInt(mapped[2], 16) + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}` + } + } + if (isIP(value)) return value + return domainToASCII(value) || value +} + +export function hasIPv6Zone(host: string): boolean { + const value = stripHostBrackets(host) + return value.includes("%") && isIP(value) === 6 +} + +export function stripIPv6Zone(host: string): string { + const value = stripHostBrackets(host) + return hasIPv6Zone(value) ? value.slice(0, value.lastIndexOf("%")) : value +} + +export function isWildcardHost(host: string): boolean { + const value = normalizeNetworkHost(host) + if (value === "0.0.0.0") return true + return isIP(value) === 6 && value.split(":").every((segment) => segment === "" || /^0+$/.test(segment)) +} + +export function isLoopbackHost(host: string): boolean { + const value = normalizeNetworkHost(host) + if (value === "localhost") return true + if (isIP(value) === 4) return value.startsWith("127.") + if (isIP(value) !== 6) return false + + const segments = value.split(":") + const last = segments.pop() + return Boolean(last && /^0*1$/.test(last)) && segments.every((segment) => segment === "" || /^0+$/.test(segment)) +} + +export function formatHostForUrl(host: string): string { + const value = normalizeNetworkHost(host) + return isIP(value) === 6 ? `[${value}]` : value +} + +function canonicalIPv6(value: string): string | null { + if (hasIPv6Zone(value)) return null + try { + return new URL(`http://[${value}]`).hostname.slice(1, -1) + } catch { + return null + } +} diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index 65adda5f4..e65405172 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from "fastify" import { ServerMeta } from "../../api-types" +import { isLoopbackHost, isWildcardHost } from "../network-host" interface RouteDeps { @@ -18,7 +19,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta { ...meta, localPort, remotePort: remote?.port, - listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", + listeningMode: isWildcardHost(meta.host) || !isLoopbackHost(meta.host) ? "all" : "local", } } @@ -49,8 +50,4 @@ function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: nu } } -function isLoopbackHost(host: string): boolean { - return host === "127.0.0.1" || host === "::1" || host.startsWith("127.") -} - // NetworkAddress shape is resolved in ../network-addresses diff --git a/packages/server/src/server/tls.ts b/packages/server/src/server/tls.ts index 3a8661b04..7e021eb01 100644 --- a/packages/server/src/server/tls.ts +++ b/packages/server/src/server/tls.ts @@ -2,7 +2,9 @@ import crypto from "crypto" import fs from "fs" import path from "path" import { createRequire } from "module" +import { isIP } from "node:net" import type { Logger } from "../logger" +import { isWildcardHost, normalizeNetworkHost, stripIPv6Zone } from "./network-host" const require = createRequire(import.meta.url) @@ -69,11 +71,11 @@ function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions try { if (!fs.existsSync(certPath)) return true const pem = fs.readFileSync(certPath, "utf-8") - const x509 = new crypto.X509Certificate(pem) - const validToMs = Date.parse(x509.validTo) + const certificate = new crypto.X509Certificate(pem) + const validToMs = Date.parse(certificate.validTo) if (!Number.isFinite(validToMs)) return true const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000 - return Date.now() >= rotateAt + return Date.now() >= rotateAt || !certificateCoversRequiredSans(certificate, args.host, args.tlsSANs) } catch { return true } @@ -219,49 +221,81 @@ function generateServerCertificate(args: { } function pickCommonName(host: string): string { - if (!host || host === "0.0.0.0") { + const normalizedHost = normalizeCertificateHost(host) + if (!normalizedHost || isWildcardHost(normalizedHost)) { return "localhost" } - if (host === "127.0.0.1") { + if (normalizedHost === "127.0.0.1") { return "localhost" } - return host + return normalizedHost } function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> { + const { dns, ips } = resolveRequiredSubjectAltNames(host, tlsSANs) + const altNames: Array<{ type: number; value?: string; ip?: string }> = [] + + // 2 = DNS, 7 = IP + for (const name of dns) { + altNames.push({ type: 2, value: name }) + } + for (const ip of ips) { + altNames.push({ type: 7, ip }) + } + + return altNames +} + +function resolveRequiredSubjectAltNames(host: string, tlsSANs?: string): { dns: Set; ips: Set } { const dns = new Set() const ips = new Set() dns.add("localhost") ips.add("127.0.0.1") - if (host && host !== "0.0.0.0") { - if (isIPv4(host)) { - ips.add(host) + const normalizedHost = normalizeCertificateHost(host) + if (isWildcardHost(normalizedHost) && isIP(normalizedHost) === 6) { + ips.add("::1") + } else if (normalizedHost) { + if (isIP(normalizedHost)) { + ips.add(normalizedHost) } else { - dns.add(host) + dns.add(normalizedHost) } } for (const token of splitList(tlsSANs)) { - if (isIPv4(token)) { - ips.add(token) - } else if (token) { - dns.add(token) + const normalizedToken = normalizeCertificateHost(token) + if (isIP(normalizedToken)) { + ips.add(normalizedToken) + } else if (normalizedToken) { + dns.add(normalizedToken) } } - const altNames: Array<{ type: number; value?: string; ip?: string }> = [] + return { dns, ips } +} - // 2 = DNS, 7 = IP - for (const name of Array.from(dns)) { - altNames.push({ type: 2, value: name }) +function normalizeCertificateHost(host: string): string { + return normalizeNetworkHost(stripIPv6Zone(host)) +} + +function certificateCoversRequiredSans(certificate: crypto.X509Certificate, host: string, tlsSANs?: string): boolean { + const { dns, ips } = resolveRequiredSubjectAltNames(host, tlsSANs) + const existingDns = new Set( + (certificate.subjectAltName ?? "") + .split(/,\s*/) + .filter((entry) => entry.startsWith("DNS:")) + .map((entry) => entry.slice(4).toLowerCase()), + ) + + for (const name of dns) { + if (!existingDns.has(name.toLowerCase())) return false } - for (const ip of Array.from(ips)) { - altNames.push({ type: 7, ip }) + for (const ip of ips) { + if (!certificate.checkIP(ip)) return false } - - return altNames + return true } function splitList(input: string | undefined): string[] { @@ -271,13 +305,3 @@ function splitList(input: string | undefined): string[] { .map((part) => part.trim()) .filter(Boolean) } - -function isIPv4(value: string): boolean { - const parts = value.split(".") - if (parts.length !== 4) return false - return parts.every((part) => { - if (!/^[0-9]+$/.test(part)) return false - const num = Number(part) - return Number.isInteger(num) && num >= 0 && num <= 255 - }) -} diff --git a/packages/ui/src/components/settings/info-settings-diagnostics.test.ts b/packages/ui/src/components/settings/info-settings-diagnostics.test.ts new file mode 100644 index 000000000..2442f2a0a --- /dev/null +++ b/packages/ui/src/components/settings/info-settings-diagnostics.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { ServerMeta } from "../../../../server/src/api-types" +import { buildDiagnosticReport } from "./info-settings-diagnostics" + +const labels = { + reportTitle: "CodeNomad Diagnostic Report", + generated: "Generated", + serverVersion: "Server version", + uiVersion: "UI version", + uiSource: "UI source", + runtime: "Runtime", + platform: "Platform", + windowContext: "Window context", + os: "OS", + listeningMode: "Listening mode", + bindHost: "Bind host", + localListener: "Local listener", + remoteListener: "Remote listener", + workspaceRoot: "Workspace root", + candidateAddresses: "Candidate addresses", + modes: { local: "local", all: "all", specific: "specific" }, + scopes: { external: "external", internal: "internal", loopback: "loopback" }, +} + +const meta: ServerMeta = { + localUrl: "http://127.0.0.1:9899", + remoteUrl: "https://192.168.1.20:9898", + eventsUrl: "http://127.0.0.1:9899/api/events", + host: "0.0.0.0", + listeningMode: "all", + localPort: 9899, + remotePort: 9898, + hostLabel: "0.0.0.0", + workspaceRoot: "/home/user/projects", + addresses: [ + { ip: "192.168.1.20", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.20:9898" }, + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + ], + serverVersion: "1.2.3", + ui: { version: "1.2.3", source: "bundled" }, +} + +describe("buildDiagnosticReport", () => { + it("includes effective connectivity details and candidate addresses", () => { + const report = buildDiagnosticReport( + meta, + "Linux x86_64", + { host: "tauri", platform: "desktop", windowContext: "local" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Generated: 2026-08-10T12:00:00\.000Z/) + assert.match(report, /Listening mode: all/) + assert.match(report, /Bind host: 0\.0\.0\.0/) + assert.match(report, /Local listener: http:\/\/127\.0\.0\.1:9899/) + assert.match(report, /Remote listener: https:\/\/192\.168\.1\.20:9898/) + assert.match(report, /Candidate addresses: 2/) + assert.match(report, /ipv4\/external: https:\/\/192\.168\.1\.20:9898/) + }) + + it("uses explicit fallbacks when server metadata is unavailable", () => { + const report = buildDiagnosticReport( + null, + "Unknown", + { host: "web", platform: "web", windowContext: "remote" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Server version: —/) + assert.match(report, /Remote listener: —/) + assert.match(report, /Candidate addresses: 0/) + }) + + it("uses caller-provided labels for exported reports", () => { + const report = buildDiagnosticReport( + meta, + "Linux x86_64", + { host: "tauri", platform: "desktop", windowContext: "local" }, + { + ...labels, + reportTitle: "Rapport de diagnostic CodeNomad", + generated: "Généré", + listeningMode: "Mode d’écoute", + modes: { ...labels.modes, all: "Toutes les interfaces réseau" }, + scopes: { ...labels.scopes, external: "Réseau" }, + }, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Rapport de diagnostic CodeNomad/) + assert.match(report, /Généré: 2026-08-10T12:00:00\.000Z/) + assert.match(report, /Mode d’écoute: Toutes les interfaces réseau/) + assert.match(report, /ipv4\/Réseau:/) + }) + + it("identifies a specific interface and omits its unreachable loopback candidate", () => { + const report = buildDiagnosticReport( + { ...meta, host: "192.168.1.20", addresses: meta.addresses }, + "Linux x86_64", + { host: "electron", platform: "desktop", windowContext: "local" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Listening mode: specific/) + assert.match(report, /Candidate addresses: 1/) + assert.doesNotMatch(report, /https:\/\/127\.0\.0\.1:9898/) + }) + + it("retains only the configured concrete loopback address", () => { + const report = buildDiagnosticReport( + { + ...meta, + host: "127.0.0.2", + listeningMode: "local", + remoteUrl: "https://127.0.0.2:9898", + addresses: [ + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + { ip: "127.0.0.2", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.2:9898" }, + ], + }, + "Windows x64", + { host: "electron", platform: "desktop", windowContext: "local" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(report, /Listening mode: local/) + assert.match(report, /Candidate addresses: 1/) + assert.match(report, /https:\/\/127\.0\.0\.2:9898/) + assert.doesNotMatch(report, /https:\/\/127\.0\.0\.1:9898/) + }) + + it("recognizes expanded IPv6 wildcards and concrete IPv6 loopback binds", () => { + const wildcard = buildDiagnosticReport( + { ...meta, host: "0:0:0:0:0:0:0:0", addresses: [] }, + "Linux arm64", + { host: "tauri", platform: "desktop", windowContext: "local" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + const loopback = buildDiagnosticReport( + { + ...meta, + host: "::1", + listeningMode: "local", + remoteUrl: "https://[::1]:9898", + addresses: [ + { ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" }, + { ip: "::1", family: "ipv6", scope: "loopback", remoteUrl: "https://[::1]:9898" }, + ], + }, + "Linux arm64", + { host: "tauri", platform: "desktop", windowContext: "local" }, + labels, + new Date("2026-08-10T12:00:00.000Z"), + ) + + assert.match(wildcard, /Listening mode: all/) + assert.match(loopback, /Listening mode: local/) + assert.match(loopback, /ipv6\/loopback: https:\/\/\[::1\]:9898/) + assert.doesNotMatch(loopback, /https:\/\/127\.0\.0\.1:9898/) + }) +}) diff --git a/packages/ui/src/components/settings/info-settings-diagnostics.ts b/packages/ui/src/components/settings/info-settings-diagnostics.ts new file mode 100644 index 000000000..bb9f2d1db --- /dev/null +++ b/packages/ui/src/components/settings/info-settings-diagnostics.ts @@ -0,0 +1,88 @@ +import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types" + +export interface DiagnosticRuntime { + host: string + platform: string + windowContext: string +} + +export interface DiagnosticLabels { + reportTitle: string + generated: string + serverVersion: string + uiVersion: string + uiSource: string + runtime: string + platform: string + windowContext: string + os: string + listeningMode: string + bindHost: string + localListener: string + remoteListener: string + workspaceRoot: string + candidateAddresses: string + modes: Record + scopes: Record +} + +export type DiagnosticListeningMode = ServerMeta["listeningMode"] | "specific" + +export function getDiagnosticListeningMode(meta: ServerMeta): DiagnosticListeningMode { + if (isWildcardBindHost(meta.host)) return "all" + if (meta.listeningMode === "all") return "specific" + return meta.listeningMode +} + +export function getDiagnosticAddresses(meta: ServerMeta): NetworkAddress[] { + if (isWildcardBindHost(meta.host)) return meta.addresses + const host = normalizeBindHost(meta.host) + return meta.addresses.filter((address) => normalizeBindHost(address.ip) === host) +} + +function isWildcardBindHost(host: string): boolean { + const value = normalizeBindHost(host) + if (value === "0.0.0.0") return true + return value.includes(":") && value.split(":").every((segment) => segment === "" || /^0+$/.test(segment)) +} + +function normalizeBindHost(host: string): string { + const value = host.trim().toLowerCase() + return value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value +} + +export function buildDiagnosticReport( + meta: ServerMeta | null, + osDisplay: string, + runtime: DiagnosticRuntime, + labels: DiagnosticLabels, + generatedAt = new Date(), +): string { + const addresses = meta ? getDiagnosticAddresses(meta) : [] + const listeningMode = meta ? labels.modes[getDiagnosticListeningMode(meta)] : "—" + const lines = [ + labels.reportTitle, + "============================", + `${labels.generated}: ${generatedAt.toISOString()}`, + `${labels.serverVersion}: ${meta?.serverVersion ?? "—"}`, + `${labels.uiVersion}: ${meta?.ui?.version ?? "—"}`, + `${labels.uiSource}: ${meta?.ui?.source ?? "—"}`, + `${labels.runtime}: ${runtime.host}`, + `${labels.platform}: ${runtime.platform}`, + `${labels.windowContext}: ${runtime.windowContext}`, + `${labels.os}: ${osDisplay}`, + `${labels.listeningMode}: ${listeningMode}`, + `${labels.bindHost}: ${meta?.host ?? "—"}`, + `${labels.localListener}: ${meta?.localUrl ?? "—"}`, + `${labels.remoteListener}: ${meta?.remoteUrl ?? "—"}`, + `${labels.workspaceRoot}: ${meta?.workspaceRoot ?? "—"}`, + `${labels.candidateAddresses}: ${addresses.length}`, + ] + + for (const address of addresses) { + lines.push(`- ${address.family}/${labels.scopes[address.scope]}: ${address.remoteUrl}`) + } + + lines.push("") + return lines.join("\n") +} diff --git a/packages/ui/src/components/settings/info-settings-section.tsx b/packages/ui/src/components/settings/info-settings-section.tsx index cc1f19371..fa7f4565b 100644 --- a/packages/ui/src/components/settings/info-settings-section.tsx +++ b/packages/ui/src/components/settings/info-settings-section.tsx @@ -1,9 +1,11 @@ -import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from "solid-js" -import { Info } from "lucide-solid" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js" +import { Info, Network } from "lucide-solid" +import { copyToClipboard } from "../../lib/clipboard" import { useI18n } from "../../lib/i18n" import { getServerMeta } from "../../lib/server-meta" -import { runtimeEnv } from "../../lib/runtime-env" -import type { ServerMeta } from "../../../../server/src/api-types" +import { canOpenRemoteWindows, runtimeEnv } from "../../lib/runtime-env" +import { openSettings } from "../../stores/settings-screen" +import { buildDiagnosticReport, getDiagnosticAddresses, getDiagnosticListeningMode } from "./info-settings-diagnostics" interface UserAgentData { platform?: string @@ -62,36 +64,6 @@ async function resolveArchitecture(): Promise { } } -function buildDiagnosticReport( - meta: ServerMeta | null, - osDisplay: string, -): string { - const lines: string[] = [] - lines.push("CodeNomad Diagnostic Report") - lines.push("============================") - lines.push(`Generated: ${new Date().toISOString()}`) - lines.push(`Server version: ${meta?.serverVersion ?? "unknown"}`) - lines.push(`UI version: ${meta?.ui?.version ?? "unknown"} (source: ${meta?.ui?.source ?? "unknown"})`) - lines.push(`Runtime: ${runtimeEnv.host}`) - lines.push(`Platform: ${runtimeEnv.platform}`) - lines.push(`Window context: ${runtimeEnv.windowContext}`) - lines.push(`OS: ${osDisplay}`) - lines.push(`Server URL: ${meta?.localUrl ?? "unknown"}`) - lines.push(`Workspace root: ${meta?.workspaceRoot ?? "unknown"}`) - lines.push(`UI source: ${meta?.ui?.source ?? "unknown"}`) - lines.push("") - return lines.join("\n") -} - -async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - return false - } -} - function downloadTextFile(filename: string, text: string) { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }) const url = URL.createObjectURL(blob) @@ -122,7 +94,16 @@ function versionNewer(current: string, latest: string): boolean | null { export const InfoSettingsSection: Component = () => { const { t } = useI18n() - const [meta, { mutate }] = createResource(() => getServerMeta()) + const [metaLoadFailed, setMetaLoadFailed] = createSignal(false) + const [meta, { mutate }] = createResource(async () => { + setMetaLoadFailed(false) + try { + return await getServerMeta() + } catch { + setMetaLoadFailed(true) + return null + } + }) const [copyFeedback, setCopyFeedback] = createSignal<"success" | "error" | null>(null) const [osArch, setOsArch] = createSignal(null) @@ -140,6 +121,34 @@ export const InfoSettingsSection: Component = () => { const supportInfo = createMemo(() => meta()?.support ?? null) + const diagnosticLabels = createMemo(() => ({ + reportTitle: t("settings.info.diagnostics.reportTitle"), + generated: t("settings.info.diagnostics.generated"), + serverVersion: t("settings.info.version.server"), + uiVersion: t("settings.info.version.ui"), + uiSource: t("settings.info.version.uiSource"), + runtime: t("settings.info.runtime.type"), + platform: t("settings.info.runtime.platform"), + windowContext: t("settings.info.runtime.windowContext"), + os: t("settings.info.runtime.os"), + listeningMode: t("remoteAccess.sections.listeningMode.label"), + bindHost: t("settings.info.connectivity.host"), + localListener: t("settings.info.connectivity.localListener"), + remoteListener: t("settings.info.connectivity.remoteListener"), + workspaceRoot: t("settings.info.server.root"), + candidateAddresses: t("remoteAccess.sections.addresses.label"), + modes: { + local: t("settings.info.connectivity.mode.local"), + all: t("settings.info.connectivity.mode.all"), + specific: t("settings.info.connectivity.mode.specific"), + }, + scopes: { + external: t("remoteAccess.address.scope.network"), + internal: t("remoteAccess.address.scope.internal"), + loopback: t("remoteAccess.address.scope.loopback"), + }, + })) + const latestVersion = createMemo(() => { const update = updateInfo() if (update?.version) return update.version @@ -171,8 +180,13 @@ export const InfoSettingsSection: Component = () => { onCleanup(() => clearTimeout(feedbackTimer)) const handleRefresh = async () => { - const fresh = await getServerMeta(true) - mutate(fresh) + setMetaLoadFailed(false) + try { + const fresh = await getServerMeta(true) + mutate(fresh) + } catch { + setMetaLoadFailed(true) + } } const osDisplay = createMemo(() => { @@ -182,14 +196,14 @@ export const InfoSettingsSection: Component = () => { }) const handleCopy = async () => { - const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv, diagnosticLabels()) const ok = await copyToClipboard(report) if (ok) setCopyFeedback("success") else setCopyFeedback("error") } const handleDownload = () => { - const report = buildDiagnosticReport(meta() ?? null, osDisplay()) + const report = buildDiagnosticReport(meta() ?? null, osDisplay(), runtimeEnv, diagnosticLabels()) const ts = new Date().toISOString().replace(/[:.]/g, "-") downloadTextFile(`codenomad-diagnostics-${ts}.txt`, report) } @@ -208,10 +222,6 @@ export const InfoSettingsSection: Component = () => {
-
- {t("settings.info.version.server")} - {meta()?.serverVersion ?? "—"} -
{t("settings.info.version.ui")} {meta()?.ui?.version ?? "—"} @@ -234,19 +244,99 @@ export const InfoSettingsSection: Component = () => { {t("settings.info.runtime.os")} {osDisplay()}
-
- {t("settings.info.server.url")} - - {meta()?.localUrl ?? "—"} - -
-
- {t("settings.info.server.root")} - - {meta()?.workspaceRoot ?? "—"} - +
+
+ +
+
+
+ +
+

{t("settings.info.connectivity.title")}

+

{t("settings.info.connectivity.subtitle")}

+
+ + +
+ {t("remoteAccess.addresses.loading")} +
+
+ + + + + {(serverMeta) => ( + <> +
+
+
{t("settings.info.version.server")}
+
{serverMeta().serverVersion ?? "—"}
+
+
+
{t("remoteAccess.sections.listeningMode.label")}
+
+ {t(getDiagnosticListeningMode(serverMeta()) === "specific" + ? "settings.info.connectivity.mode.specific" + : getDiagnosticListeningMode(serverMeta()) === "all" + ? "settings.info.connectivity.mode.all" + : "settings.info.connectivity.mode.local")} +
+
+
+
{t("settings.info.connectivity.host")}
+
{serverMeta().host}
+
+
+
{t("settings.info.connectivity.localListener")}
+
{serverMeta().localUrl}
+
+ + {(remoteUrl) => ( +
+
{t("settings.info.connectivity.remoteListener")}
+
{remoteUrl()}
+
+ )} +
+
+
{t("settings.info.server.root")}
+
{serverMeta().workspaceRoot}
+
+
+ +

{t("remoteAccess.sections.addresses.label")}

+ 0} fallback={
{t("remoteAccess.addresses.none")}
}> +
+ {(address) => ( +
+
+ {address.family.toUpperCase()} · {t(address.scope === "external" + ? "remoteAccess.address.scope.network" + : address.scope === "internal" + ? "remoteAccess.address.scope.internal" + : "remoteAccess.address.scope.loopback")} +
+
{address.remoteUrl}
+
+ )}
+
+
+

{t("settings.info.connectivity.disclaimer")}

+ + +
+ +
+
+ + )} +
@@ -300,6 +390,8 @@ export const InfoSettingsSection: Component = () => {
+
{t("settings.info.diagnostics.privacy")}
+