From 338a88fb5afc63ec2d181218c7e045ad6043dbac Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 8 Feb 2026 15:48:00 +0000 Subject: [PATCH] feat(server): add HTTPS with self-signed certs Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output. --- package-lock.json | 21 ++ .../electron/main/process-manager.ts | 51 ++-- packages/server/README.md | 53 +++- packages/server/package.json | 4 +- packages/server/src/api-types.ts | 15 +- packages/server/src/auth/manager.ts | 13 +- packages/server/src/index.ts | 218 ++++++++++++-- packages/server/src/server/http-server.ts | 69 +++-- .../server/src/server/network-addresses.ts | 75 +++++ packages/server/src/server/routes/auth.ts | 15 +- packages/server/src/server/routes/meta.ts | 102 ++----- packages/server/src/server/tls.ts | 283 ++++++++++++++++++ packages/server/src/workspaces/manager.ts | 3 + .../tauri-app/src-tauri/src/cli_manager.rs | 79 +++-- .../src/components/remote-access-overlay.tsx | 87 ++++-- packages/ui/src/lib/api-client.ts | 3 +- 16 files changed, 866 insertions(+), 225 deletions(-) create mode 100644 packages/server/src/server/network-addresses.ts create mode 100644 packages/server/src/server/tls.ts diff --git a/package-lock.json b/package-lock.json index 139128e1..a60ab8c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3453,6 +3453,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/plist": { "version": "3.0.5", "dev": true, @@ -8059,6 +8069,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "dev": true, @@ -11980,6 +11999,7 @@ "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", + "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", "yauzl": "^2.10.0", @@ -11989,6 +12009,7 @@ "codenomad": "dist/bin.js" }, "devDependencies": { + "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", "ts-node": "^10.9.2", diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 76ae2271..bcf84b46 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -347,38 +347,27 @@ export class CliProcessManager extends EventEmitter { console.info(`[cli][${stream}] ${trimmed}`) this.emit("log", { stream, message: trimmed }) - const port = this.extractPort(trimmed) - if (port && this.status.state === "starting") { - const url = `http://127.0.0.1:${port}` - console.info(`[cli] ready on ${url}`) - this.updateStatus({ state: "ready", port, url }) + const localUrl = this.extractLocalUrl(trimmed) + if (localUrl && this.status.state === "starting") { + let port: number | undefined + try { + port = Number(new URL(localUrl).port) || undefined + } catch { + port = undefined + } + console.info(`[cli] ready on ${localUrl}`) + this.updateStatus({ state: "ready", port, url: localUrl }) this.emit("ready", this.status) } } } - private extractPort(line: string): number | null { - const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i) - if (readyMatch) { - return parseInt(readyMatch[1], 10) + private extractLocalUrl(line: string): string | null { + const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i) + if (!match) { + return null } - - if (line.toLowerCase().includes("http server listening")) { - const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/) - if (httpMatch) { - return parseInt(httpMatch[1], 10) - } - try { - const parsed = JSON.parse(line) - if (typeof parsed.port === "number") { - return parsed.port - } - } catch { - // not JSON, ignore - } - } - - return null + return match[1] ?? null } private updateStatus(patch: Partial) { @@ -387,7 +376,15 @@ export class CliProcessManager extends EventEmitter { } private buildCliArgs(options: StartOptions, host: string): string[] { - const args = ["serve", "--host", host, "--port", "0", "--generate-token"] + const args = ["serve", "--host", host, "--generate-token"] + + if (options.dev) { + // Dev: run plain HTTP + Vite dev server proxy. + args.push("--https", "false", "--http", "true") + } else { + // Prod desktop: always keep loopback HTTP enabled. + args.push("--https", "true", "--http", "true") + } if (options.dev) { args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") diff --git a/packages/server/README.md b/packages/server/README.md index 87146fc8..2eff0a24 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it: npx @neuralnomads/codenomad --launch ``` +On startup, CodeNomad prints two URLs: + +- `Local Connection URL : ...` (used by desktop shells) +- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) + ### Install Globally Or install it globally to use the `codenomad` command: @@ -44,7 +49,14 @@ You can configure the server using flags or environment variables: | Flag | Env Variable | Description | |------|--------------|-------------| -| `--port ` | `CLI_PORT` | HTTP port (default 9898) | +| `--https ` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) | +| `--http ` | `CLI_HTTP` | Enable HTTP listener (default `false`) | +| `--https-port ` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) | +| `--http-port ` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) | +| `--tls-key ` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. | +| `--tls-cert ` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. | +| `--tls-ca ` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | +| `--tlsSANs ` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--host ` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--workspace-root ` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | @@ -56,6 +68,42 @@ You can configure the server using flags or environment variables: | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | +### HTTP vs HTTPS + +- Default: `--https=true --http=false` (HTTPS only). +- To run plain HTTP only (useful for development): + +```sh +codenomad --https=false --http=true +``` + +- To run both HTTPS (for remote) and HTTP loopback (for desktop): + +```sh +codenomad --https=true --http=true +``` + +### Remote Access Binding Rules + +- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`): + - HTTP listens on `127.0.0.1` only. + - HTTPS listens on `--host` (LAN/all interfaces). +- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`): + - Both HTTP and HTTPS listen on `127.0.0.1`. + +### Self-Signed Certificates + +If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory: + +- `~/.config/codenomad/tls/ca-cert.pem` +- `~/.config/codenomad/tls/server-cert.pem` + +Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via: + +```sh +codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10" +``` + ### Authentication - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. @@ -71,8 +119,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo > **TLS requirement** > Browsers require a secure (`https://`) connection for PWA installation. -> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate. -> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). +> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). ### Data Storage - **Config**: `~/.config/codenomad/config.json` diff --git a/packages/server/package.json b/packages/server/package.json index 588aacf1..e6ab400d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,7 +21,7 @@ "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", "prepare-config": "node ./scripts/copy-opencode-config.mjs", - "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", + "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -31,12 +31,14 @@ "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", + "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", "yauzl": "^2.10.0", "zod": "^3.23.8" }, "devDependencies": { + "@types/node-forge": "^1.3.14", "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", "ts-node": "^10.9.2", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 477207e2..41e8229b 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -236,7 +236,8 @@ export interface NetworkAddress { ip: string family: "ipv4" | "ipv6" scope: "external" | "internal" | "loopback" - url: string + /** Remote URL using the server's remote protocol/port for this IP. */ + remoteUrl: string } export interface LatestReleaseInfo { @@ -262,16 +263,20 @@ export interface SupportMeta { } export interface ServerMeta { - /** Base URL clients should target for REST calls (useful for Electron embedding). */ - httpBaseUrl: string + /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */ + localUrl: string + /** URL remote clients should use (prefers HTTPS when enabled). */ + remoteUrl?: string /** SSE endpoint advertised to clients (`/api/events` by default). */ eventsUrl: string /** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */ host: string /** Listening mode derived from host binding. */ listeningMode: "local" | "all" - /** Actual port in use after binding. */ - port: number + /** Actual local port in use after binding. */ + localPort: number + /** Actual remote port in use after binding (when remoteUrl is set). */ + remotePort?: number /** Display label for the host (e.g., hostname or friendly name). */ hostLabel: string /** Absolute path of the filesystem root exposed to clients. */ diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index ebb7ec79..dde0f72f 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -119,10 +119,18 @@ export class AuthManager { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId)) } + setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) { + reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options)) + } + clearSessionCookie(reply: FastifyReply) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) } + clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) { + reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options })) + } + private requireAuthStore(): AuthStore { if (!this.authStore) { throw new Error("Auth store is unavailable") @@ -143,8 +151,11 @@ function resolvePath(filePath: string) { return path.resolve(filePath) } -function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) { +function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) { const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"] + if (options?.secure) { + parts.push("Secure") + } if (options?.maxAgeSeconds !== undefined) { parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9adffec4..e29313de 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,6 +19,8 @@ import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" +import { resolveHttpsOptions } from "./server/tls" +import { resolveNetworkAddresses } from "./server/network-addresses" const require = createRequire(import.meta.url) @@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename) const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") interface CliOptions { - port: number host: string + https: boolean + http: boolean + httpsPort: number + httpPort: number + tlsKeyPath?: string + tlsCertPath?: string + tlsCaPath?: string + tlsSANs?: string rootDir: string configPath: string unrestrictedRoot: boolean @@ -47,9 +56,10 @@ interface CliOptions { dangerouslySkipAuth: boolean } -const DEFAULT_PORT = 9898 const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" +const DEFAULT_HTTPS_PORT = 9898 +const DEFAULT_HTTP_PORT = 9899 function parseCliOptions(argv: string[]): CliOptions { const program = new Command() @@ -57,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions { .description("CodeNomad CLI server") .version(packageJson.version, "-v, --version", "Show the CLI version") .addOption(new Option("--host ", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) - .addOption(new Option("--port ", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort)) + .addOption(new Option("--https ", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true")) + .addOption(new Option("--http ", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false")) + .addOption(new Option("--https-port ", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort)) + .addOption(new Option("--http-port ", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort)) + .addOption(new Option("--tls-key ", "TLS private key (PEM)").env("CLI_TLS_KEY")) + .addOption(new Option("--tls-cert ", "TLS certificate (PEM)").env("CLI_TLS_CERT")) + .addOption(new Option("--tls-ca ", "TLS CA chain (PEM)").env("CLI_TLS_CA")) + .addOption(new Option("--tlsSANs ", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption( new Option("--workspace-root ", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()), ) @@ -97,7 +114,14 @@ function parseCliOptions(argv: string[]): CliOptions { program.parse(argv, { from: "user" }) const parsed = program.opts<{ host: string - port: number + https?: string + http?: string + httpsPort: number + httpPort: number + tlsKey?: string + tlsCert?: string + tlsCa?: string + tlsSANs?: string workspaceRoot?: string root?: string unrestrictedRoot?: boolean @@ -128,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions { const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" + const httpsEnabled = parseBooleanEnv(parsed.https) + const httpEnabled = parseBooleanEnv(parsed.http) + + if (!httpsEnabled && !httpEnabled) { + throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)") + } + return { - port: parsed.port, host: normalizedHost, + https: httpsEnabled, + http: httpEnabled, + httpsPort: parsed.httpsPort, + httpPort: parsed.httpPort, + tlsKeyPath: parsed.tlsKey, + tlsCertPath: parsed.tlsCert, + tlsCaPath: parsed.tlsCa, + tlsSANs: parsed.tlsSANs, rootDir: resolvedRoot, configPath: parsed.config, unrestrictedRoot: Boolean(parsed.unrestrictedRoot), @@ -172,6 +210,13 @@ function resolveHost(input: string | undefined): string { return trimmed } +function resolvePath(filePath: string) { + if (filePath.startsWith("~/")) { + return path.join(process.env.HOME ?? "", filePath.slice(2)) + } + return path.resolve(filePath) +} + function programHasArg(argv: string[], flag: string): boolean { return argv.includes(flag) } @@ -200,12 +245,20 @@ async function main() { const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") + const configDir = path.dirname(resolvePath(options.configPath)) + + if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { + throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") + } + const serverMeta: ServerMeta = { - httpBaseUrl: `http://${options.host}:${options.port}`, + localUrl: "http://localhost:0", + remoteUrl: undefined, eventsUrl: `/api/events`, host: options.host, listeningMode: isLoopbackHost(options.host) ? "local" : "all", - port: options.port, + localPort: 0, + remotePort: undefined, hostLabel: options.host, workspaceRoot: options.rootDir, addresses: [], @@ -229,6 +282,19 @@ async function main() { } } + const tlsResolution = resolveHttpsOptions({ + enabled: options.https, + configDir, + host: options.host, + tlsKeyPath: options.tlsKeyPath, + tlsCertPath: options.tlsCertPath, + tlsCaPath: options.tlsCaPath, + tlsSANs: options.tlsSANs, + logger: logger.child({ component: "tls" }), + }) + + const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined + const configStore = new ConfigStore(options.configPath, eventBus, configLogger) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const workspaceManager = new WorkspaceManager({ @@ -237,7 +303,8 @@ async function main() { binaryRegistry, eventBus, logger: workspaceLogger, - getServerBaseUrl: () => serverMeta.httpBaseUrl, + getServerBaseUrl: () => serverMeta.localUrl, + nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const instanceStore = new InstanceStore() @@ -277,28 +344,121 @@ async function main() { minServerVersion: uiResolution.minServerVersion, } - const server = createHttpServer({ - host: options.host, - port: options.port, - workspaceManager, - configStore, - binaryRegistry, - fileSystemBrowser, - eventBus, - serverMeta, - instanceStore, - authManager, - uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, - uiDevServerUrl: uiResolution.uiDevServerUrl, - logger, - }) + if (uiResolution.uiDevServerUrl && options.https) { + throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") + } - const startInfo = await server.start() - logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening") - console.log(`CodeNomad Server is ready at ${startInfo.url}`) + const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) + + const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) + const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) + + const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0 + 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" + const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" + + const servers: Array> = [] + + const httpServer = options.http + ? createHttpServer({ + bindHost: httpBindHost, + bindPort: httpBindPort, + defaultPort: options.httpPort, + protocol: "http", + workspaceManager, + configStore, + binaryRegistry, + fileSystemBrowser, + eventBus, + serverMeta, + instanceStore, + authManager, + uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, + uiDevServerUrl: uiResolution.uiDevServerUrl, + logger, + }) + : null + + const httpsServer = options.https + ? createHttpServer({ + bindHost: httpsBindHost, + bindPort: httpsBindPort, + defaultPort: options.httpsPort, + protocol: "https", + httpsOptions: tlsResolution?.httpsOptions, + workspaceManager, + configStore, + binaryRegistry, + fileSystemBrowser, + eventBus, + serverMeta, + instanceStore, + authManager, + uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, + uiDevServerUrl: undefined, + logger, + }) + : null + + if (httpServer) servers.push(httpServer) + if (httpsServer) servers.push(httpsServer) + + const [httpStart, httpsStart] = await Promise.all([ + httpServer ? httpServer.start() : Promise.resolve(null), + httpsServer ? httpsServer.start() : Promise.resolve(null), + ]) + + const localStart = httpStart ?? httpsStart + if (!localStart) { + throw new Error("No listeners started") + } + + const remoteStart = httpsStart ?? httpStart + const localProtocol: "http" | "https" = httpStart ? "http" : "https" + const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http" + + const localUrl = `${localProtocol}://localhost:${localStart.port}` + let remoteUrl: string | undefined + if (remoteStart) { + const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) + let remoteHost = options.host + if (wantsAll) { + if (options.host === "0.0.0.0") { + const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) + remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost" + } + } else { + remoteHost = "localhost" + } + remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` + } + + serverMeta.localUrl = localUrl + serverMeta.localPort = localStart.port + serverMeta.remoteUrl = remoteUrl + serverMeta.remotePort = remoteStart?.port + serverMeta.host = options.host + serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" + + if (serverMeta.remotePort && remoteUrl) { + serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) + } else { + serverMeta.addresses = [] + } + + console.log(`Local Connection URL : ${serverMeta.localUrl}`) + if (serverMeta.remoteUrl) { + console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) + } if (options.launch) { - await launchInBrowser(startInfo.url, logger.child({ component: "launcher" })) + await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) } let shuttingDown = false @@ -328,8 +488,8 @@ async function main() { const shutdownHttp = (async () => { try { - await server.stop() - logger.info("HTTP server stopped") + await Promise.allSettled(servers.map((srv) => srv.stop())) + logger.info("HTTP server(s) stopped") } catch (error) { logger.error({ err: error }, "Failed to stop HTTP server") } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index e4df2cd3..bdcb66af 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -30,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" interface HttpServerDeps { - host: string - port: number + bindHost: string + bindPort: number + /** When bindPort is 0, try this first. */ + defaultPort: number + protocol: "http" | "https" + httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } workspaceManager: WorkspaceManager configStore: ConfigStore binaryRegistry: BinaryRegistry @@ -51,10 +55,15 @@ interface HttpServerStartResult { displayHost: string } -const DEFAULT_HTTP_PORT = 9898 - export function createHttpServer(deps: HttpServerDeps) { - const app = Fastify({ logger: false }) + // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS. + // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance. + const app = Fastify( + ({ + logger: false, + ...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}), + } as unknown) as any, + ) as unknown as FastifyInstance const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) @@ -97,6 +106,27 @@ 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] + for (const candidate of candidates) { + if (!candidate) continue + try { + origins.add(new URL(candidate).origin) + } catch { + // ignore + } + } + for (const addr of deps.serverMeta.addresses ?? []) { + try { + origins.add(new URL(addr.remoteUrl).origin) + } catch { + // ignore + } + } + return origins + } + app.register(cors, { origin: (origin, cb) => { if (!origin) { @@ -104,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) { return } - let selfOrigin: string | null = null - try { - selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin - } catch { - selfOrigin = null - } - - if (selfOrigin && origin === selfOrigin) { + const selfOrigins = getSelfOrigins() + if (selfOrigins.has(origin)) { cb(null, true) return } @@ -122,7 +146,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.host === "0.0.0.0" || !isLoopbackHost(deps.host)) { + if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) { cb(null, true) return } @@ -245,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) { instance: app, start: async (): Promise => { const attemptListen = async (requestedPort: number) => { - const addressInfo = await app.listen({ port: requestedPort, host: deps.host }) + const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost }) return { addressInfo, requestedPort } } - const autoPortRequested = deps.port === 0 - const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port + const autoPortRequested = deps.bindPort === 0 + const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort const shouldRetryWithEphemeral = (error: unknown) => { if (!autoPortRequested) return false @@ -286,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) { } } - const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host - const serverUrl = `http://${displayHost}:${actualPort}` + const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost + const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}` - deps.serverMeta.httpBaseUrl = serverUrl - deps.serverMeta.host = deps.host - deps.serverMeta.port = actualPort - deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local" - deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening") - console.log(`CodeNomad Server is ready at ${serverUrl}`) + deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening") return { port: actualPort, url: serverUrl, displayHost } }, diff --git a/packages/server/src/server/network-addresses.ts b/packages/server/src/server/network-addresses.ts new file mode 100644 index 00000000..ffedd821 --- /dev/null +++ b/packages/server/src/server/network-addresses.ts @@ -0,0 +1,75 @@ +import os from "os" +import type { NetworkAddress } from "../api-types" + +export function resolveNetworkAddresses(args: { + host: string + protocol: "http" | "https" + port: number +}): NetworkAddress[] { + const { host, protocol, port } = args + 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}` + if (seen.has(key)) return + seen.add(key) + results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${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) + 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 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") + } + + const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } + + return results.sort((a, b) => { + const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] + if (scopeDelta !== 0) return scopeDelta + return a.ip.localeCompare(b.ip) + }) +} + +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 + }) +} diff --git a/packages/server/src/server/routes/auth.ts b/packages/server/src/server/routes/auth.ts index 13401f23..e47da74a 100644 --- a/packages/server/src/server/routes/auth.ts +++ b/packages/server/src/server/routes/auth.ts @@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { } const session = deps.authManager.createSession(body.username) - deps.authManager.setSessionCookie(reply, session.id) + deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) }) reply.send({ ok: true }) }) @@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { const username = deps.authManager.getStatus().username const session = deps.authManager.createSession(username) - deps.authManager.setSessionCookie(reply, session.id) + deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) }) reply.send({ ok: true }) }) - app.post("/api/auth/logout", async (_request, reply) => { - deps.authManager.clearSessionCookie(reply) + app.post("/api/auth/logout", async (request, reply) => { + deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) }) reply.send({ ok: true }) }) @@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { }) } +function isSecureRequest(request: any) { + if (request.protocol === "https") { + return true + } + return Boolean(request.raw?.socket && request.raw.socket.encrypted) +} + function escapeHtml(value: string) { return value.replace(/[&<>"]/g, (char) => { switch (char) { diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index 40782181..ef01c4cb 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from "fastify" -import os from "os" -import { NetworkAddress, ServerMeta } from "../../api-types" +import { ServerMeta } from "../../api-types" +import { resolveNetworkAddresses } from "../network-addresses" interface RouteDeps { serverMeta: ServerMeta @@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { } function buildMetaResponse(meta: ServerMeta): ServerMeta { - const port = resolvePort(meta) - const addresses = port > 0 ? resolveAddresses(port, meta.host) : [] + const localPort = resolveLocalPort(meta) + const remote = resolveRemote(meta) + const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : [] return { ...meta, - port, + localPort, + remotePort: remote?.port, listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", addresses, } } -function resolvePort(meta: ServerMeta): number { - if (Number.isInteger(meta.port) && meta.port > 0) { - return meta.port +function resolveLocalPort(meta: ServerMeta): number { + if (Number.isInteger(meta.localPort) && meta.localPort > 0) { + return meta.localPort } try { - const parsed = new URL(meta.httpBaseUrl) + const parsed = new URL(meta.localUrl) const port = Number(parsed.port) return Number.isInteger(port) && port > 0 ? port : 0 } catch { @@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number { } } +function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null { + if (!meta.remoteUrl) { + return null + } + try { + const parsed = new URL(meta.remoteUrl) + const protocol = parsed.protocol === "https:" ? "https" : "http" + const port = Number(parsed.port) + return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 } + } catch { + return null + } +} + function isLoopbackHost(host: string): boolean { return host === "127.0.0.1" || host === "::1" || host.startsWith("127.") } -function resolveAddresses(port: number, host: string): NetworkAddress[] { - 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}` - if (seen.has(key)) return - seen.add(key) - results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${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) - 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 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") - } - - const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } - - return results.sort((a, b) => { - const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] - if (scopeDelta !== 0) return scopeDelta - return a.ip.localeCompare(b.ip) - }) -} - -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 - }) -} +// NetworkAddress shape is resolved in ../network-addresses diff --git a/packages/server/src/server/tls.ts b/packages/server/src/server/tls.ts new file mode 100644 index 00000000..3a8661b0 --- /dev/null +++ b/packages/server/src/server/tls.ts @@ -0,0 +1,283 @@ +import crypto from "crypto" +import fs from "fs" +import path from "path" +import { createRequire } from "module" +import type { Logger } from "../logger" + +const require = createRequire(import.meta.url) + +type Forge = typeof import("node-forge") + +function loadForge(): Forge { + // node-forge is CJS in many installs; require keeps this compatible with our ESM output. + return require("node-forge") as Forge +} + +export interface ResolvedHttpsOptions { + httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } + /** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */ + caCertPath?: string + mode: "provided" | "generated" +} + +export interface ResolveHttpsOptionsArgs { + enabled: boolean + configDir: string + host: string + tlsKeyPath?: string + tlsCertPath?: string + tlsCaPath?: string + tlsSANs?: string + logger: Logger +} + +const LEAF_VALIDITY_DAYS = 30 +const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3 + +const CA_VALIDITY_DAYS = 365 + +export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null { + if (!args.enabled) { + return null + } + + const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath) + if (hasProvided) { + const key = fs.readFileSync(args.tlsKeyPath!, "utf-8") + const cert = fs.readFileSync(args.tlsCertPath!, "utf-8") + const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined + return { + httpsOptions: { key, cert, ca }, + caCertPath: args.tlsCaPath, + mode: "provided", + } + } + + return ensureGeneratedTls(args) +} + +function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions { + const tlsDir = path.join(args.configDir, "tls") + const caKeyPath = path.join(tlsDir, "ca-key.pem") + const caCertPath = path.join(tlsDir, "ca-cert.pem") + const keyPath = path.join(tlsDir, "server-key.pem") + const certPath = path.join(tlsDir, "server-cert.pem") + + fs.mkdirSync(tlsDir, { recursive: true }) + + const shouldRotateLeaf = () => { + 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) + if (!Number.isFinite(validToMs)) return true + const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000 + return Date.now() >= rotateAt + } catch { + return true + } + } + + const shouldRotateCa = () => { + try { + if (!fs.existsSync(caCertPath)) return true + const pem = fs.readFileSync(caCertPath, "utf-8") + const x509 = new crypto.X509Certificate(pem) + const validToMs = Date.parse(x509.validTo) + if (!Number.isFinite(validToMs)) return true + // CA rotates only when expired. + return Date.now() >= validToMs + } catch { + return true + } + } + + if (shouldRotateCa() || !fs.existsSync(caKeyPath)) { + const { caKeyPem, caCertPem } = generateCaCertificate() + writePemFile(caKeyPath, caKeyPem, 0o600) + writePemFile(caCertPath, caCertPem, 0o644) + args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate") + } + + if (shouldRotateLeaf() || !fs.existsSync(keyPath)) { + const caKeyPem = fs.readFileSync(caKeyPath, "utf-8") + const caCertPem = fs.readFileSync(caCertPath, "utf-8") + + const { keyPem, certPem } = generateServerCertificate({ + host: args.host, + tlsSANs: args.tlsSANs, + caKeyPem, + caCertPem, + }) + + writePemFile(keyPath, keyPem, 0o600) + writePemFile(certPath, certPem, 0o644) + args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate") + } + + const key = fs.readFileSync(keyPath, "utf-8") + const cert = fs.readFileSync(certPath, "utf-8") + const ca = fs.readFileSync(caCertPath, "utf-8") + + // Present the CA as part of the chain. + const chainedCert = `${cert.trim()}\n${ca.trim()}\n` + + return { + httpsOptions: { + key, + cert: chainedCert, + }, + caCertPath, + mode: "generated", + } +} + +function writePemFile(filePath: string, content: string, mode: number) { + fs.writeFileSync(filePath, content, { encoding: "utf-8", mode }) + try { + fs.chmodSync(filePath, mode) + } catch { + // best effort on platforms that ignore chmod + } +} + +function generateCaCertificate(): { caKeyPem: string; caCertPem: string } { + const forge = loadForge() + + const keys = forge.pki.rsa.generateKeyPair(2048) + const cert = forge.pki.createCertificate() + cert.publicKey = keys.publicKey + cert.serialNumber = crypto.randomBytes(16).toString("hex") + + const now = new Date() + const notBefore = new Date(now.getTime() - 60_000) + const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000) + cert.validity.notBefore = notBefore + cert.validity.notAfter = notAfter + + const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }] + cert.setSubject(attrs) + cert.setIssuer(attrs) + + cert.setExtensions([ + { name: "basicConstraints", cA: true }, + { name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true }, + { name: "subjectKeyIdentifier" }, + ]) + + cert.sign(keys.privateKey, forge.md.sha256.create()) + + return { + caKeyPem: forge.pki.privateKeyToPem(keys.privateKey), + caCertPem: forge.pki.certificateToPem(cert), + } +} + +function generateServerCertificate(args: { + host: string + tlsSANs?: string + caKeyPem: string + caCertPem: string +}): { keyPem: string; certPem: string } { + const forge = loadForge() + + const caKey = forge.pki.privateKeyFromPem(args.caKeyPem) + const caCert = forge.pki.certificateFromPem(args.caCertPem) + + const keys = forge.pki.rsa.generateKeyPair(2048) + const cert = forge.pki.createCertificate() + cert.publicKey = keys.publicKey + cert.serialNumber = crypto.randomBytes(16).toString("hex") + + const now = new Date() + const notBefore = new Date(now.getTime() - 60_000) + const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000) + cert.validity.notBefore = notBefore + cert.validity.notAfter = notAfter + + const commonName = pickCommonName(args.host) + cert.setSubject([{ name: "commonName", value: commonName }]) + cert.setIssuer(caCert.subject.attributes) + + const san = buildSubjectAltNames(args.host, args.tlsSANs) + + cert.setExtensions([ + { name: "basicConstraints", cA: false }, + { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, + { name: "extKeyUsage", serverAuth: true }, + { name: "subjectAltName", altNames: san }, + { name: "subjectKeyIdentifier" }, + ]) + + cert.sign(caKey, forge.md.sha256.create()) + + return { + keyPem: forge.pki.privateKeyToPem(keys.privateKey), + certPem: forge.pki.certificateToPem(cert), + } +} + +function pickCommonName(host: string): string { + if (!host || host === "0.0.0.0") { + return "localhost" + } + if (host === "127.0.0.1") { + return "localhost" + } + return host +} + +function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> { + 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) + } else { + dns.add(host) + } + } + + for (const token of splitList(tlsSANs)) { + if (isIPv4(token)) { + ips.add(token) + } else if (token) { + dns.add(token) + } + } + + const altNames: Array<{ type: number; value?: string; ip?: string }> = [] + + // 2 = DNS, 7 = IP + for (const name of Array.from(dns)) { + altNames.push({ type: 2, value: name }) + } + for (const ip of Array.from(ips)) { + altNames.push({ type: 7, ip }) + } + + return altNames +} + +function splitList(input: string | undefined): string[] { + if (!input) return [] + return input + .split(",") + .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/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index b4d309d6..8afca60b 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -28,6 +28,8 @@ interface WorkspaceManagerOptions { eventBus: EventBus logger: Logger getServerBaseUrl: () => string + /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ + nodeExtraCaCertsPath?: string } interface WorkspaceRecord extends WorkspaceDescriptor {} @@ -132,6 +134,7 @@ export class WorkspaceManager { OPENCODE_CONFIG_DIR: this.opencodeConfigDir, CODENOMAD_INSTANCE_ID: id, CODENOMAD_BASE_URL: this.options.getServerBaseUrl(), + ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, } diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 3656277c..9e3242ea 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -531,7 +531,7 @@ impl CliProcessManager { bootstrap_token: &Arc>>, ) { let mut buffer = String::new(); - let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok(); + let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; @@ -559,12 +559,12 @@ impl CliProcessManager { continue; } - if let Some(port) = port_regex + if let Some(url) = local_url_regex .as_ref() .and_then(|re| re.captures(line).and_then(|c| c.get(1))) - .and_then(|m| m.as_str().parse::().ok()) + .map(|m| m.as_str().to_string()) { - Self::mark_ready(app, status, ready, bootstrap_token, port); + Self::mark_ready(app, status, ready, bootstrap_token, url); continue; } @@ -574,13 +574,13 @@ impl CliProcessManager { .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|m| m.as_str().parse::().ok()) { - Self::mark_ready(app, status, ready, bootstrap_token, port); + Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}")); continue; } if let Ok(value) = serde_json::from_str::(line) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { - Self::mark_ready(app, status, ready, bootstrap_token, port as u16); + Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port)); continue; } } @@ -597,12 +597,15 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, - port: u16, + base_url: String, ) { ready.store(true, Ordering::SeqCst); - let base_url = format!("http://127.0.0.1:{port}"); + let port = Url::parse(&base_url) + .ok() + .and_then(|u| u.port_or_known_default()) + .map(|p| p as u16); let mut locked = status.lock(); - locked.port = Some(port); + locked.port = port; locked.url = Some(base_url.clone()); locked.state = CliState::Ready; locked.error = None; @@ -611,22 +614,29 @@ impl CliProcessManager { let token = bootstrap_token.lock().take(); if let Some(token) = token { - match exchange_bootstrap_token(&base_url, &token) { - Ok(Some(session_id)) => { - if let Err(err) = set_session_cookie(app, &base_url, &session_id) { - log_line(&format!("failed to set session cookie: {err}")); - navigate_main(app, &format!("{base_url}/login")); - } else { - navigate_main(app, &base_url); + // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS, + // skip the exchange and let the user authenticate normally. + let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string()); + if scheme.as_deref() != Some("http") { + navigate_main(app, &base_url); + } else { + match exchange_bootstrap_token(&base_url, &token) { + Ok(Some(session_id)) => { + if let Err(err) = set_session_cookie(app, &base_url, &session_id) { + log_line(&format!("failed to set session cookie: {err}")); + navigate_main(app, &format!("{base_url}/login")); + } else { + navigate_main(app, &base_url); + } + } + Ok(None) => { + log_line("bootstrap token exchange failed (invalid token)"); + navigate_main(app, &format!("{base_url}/login")); + } + Err(err) => { + log_line(&format!("bootstrap token exchange failed: {err}")); + navigate_main(app, &format!("{base_url}/login")); } - } - Ok(None) => { - log_line("bootstrap token exchange failed (invalid token)"); - navigate_main(app, &format!("{base_url}/login")); - } - Err(err) => { - log_line(&format!("bootstrap token exchange failed: {err}")); - navigate_main(app, &format!("{base_url}/login")); } } } else { @@ -709,19 +719,24 @@ impl CliEntry { } fn build_args(&self, dev: bool, host: &str) -> Vec { - let mut args = vec![ - "serve".to_string(), - "--host".to_string(), - host.to_string(), - "--port".to_string(), - "0".to_string(), - "--generate-token".to_string(), - ]; + let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()]; + if dev { + // Dev: plain HTTP + Vite dev server proxy. + args.push("--https".to_string()); + args.push("false".to_string()); + args.push("--http".to_string()); + args.push("true".to_string()); args.push("--ui-dev-server".to_string()); args.push("http://localhost:3000".to_string()); args.push("--log-level".to_string()); args.push("debug".to_string()); + } else { + // Prod desktop: always keep loopback HTTP enabled. + args.push("--https".to_string()); + args.push("true".to_string()); + args.push("--http".to_string()); + args.push("true".to_string()); } args } diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx index 1edd785e..e6589b5c 100644 --- a/packages/ui/src/components/remote-access-overlay.tsx +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { const allowExternalConnections = createMemo(() => currentMode() === "all") const displayAddresses = createMemo(() => { const list = addresses() - if (allowExternalConnections()) { - return list.filter((address) => address.scope !== "loopback") + if (!allowExternalConnections()) { + return [] } - return list.filter((address) => address.scope === "loopback") + // Local URL is displayed separately; list only remote-friendly addresses. + return list.filter((address) => address.scope !== "loopback") }) const refreshMeta = async () => { @@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { {error()}}> 0} fallback={
{t("remoteAccess.addresses.none")}
}>
- - {(address) => { - const expandedState = () => expandedUrl() === address.url - const qr = () => qrCodes()[address.url] - const scopeLabel = () => - address.scope === "external" - ? t("remoteAccess.address.scope.network") - : address.scope === "loopback" - ? t("remoteAccess.address.scope.loopback") - : t("remoteAccess.address.scope.internal") + + {(url) => { + const value = () => url() + const expandedState = () => expandedUrl() === value() + const qr = () => qrCodes()[value()] return (
-

{address.url}

-

- {address.family.toUpperCase()} • {scopeLabel()} • {address.ip} -

+

{value()}

+

{t("remoteAccess.address.scope.loopback")}

-
+ +
+ ) + }} + + + {(address) => { + const url = address.remoteUrl + const expandedState = () => expandedUrl() === url + const qr = () => qrCodes()[url] + const scopeLabel = () => + address.scope === "external" + ? t("remoteAccess.address.scope.network") + : address.scope === "loopback" + ? t("remoteAccess.address.scope.loopback") + : t("remoteAccess.address.scope.internal") + return ( +
+
+
+

{url}

+

+ {address.family.toUpperCase()} • {scopeLabel()} • {address.ip} +

+
+
+ + +
+
+ +
+