From b347d5d9ccbb8ccf008c887fc03cdc3410710afd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 02:46:07 +0000 Subject: [PATCH] feat: add gateway tls support --- .../Clawdbot/GatewayEndpointStore.swift | 33 +++++++++++++++++-- src/config/types.gateway.ts | 3 ++ src/config/zod-schema.ts | 9 +++++ src/gateway/call.ts | 6 ++-- src/gateway/server-bridge-runtime.ts | 2 ++ src/gateway/server-discovery-runtime.ts | 3 ++ src/gateway/server-http.ts | 21 +++++++++--- src/gateway/server-runtime-state.ts | 3 ++ src/gateway/server-startup-log.ts | 4 ++- src/gateway/server.impl.ts | 10 ++++++ src/gateway/server/tls.ts | 14 ++++++++ src/infra/bonjour.ts | 8 +++++ 12 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 src/gateway/server/tls.ts diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index ff1e666b2..3e9707f93 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -250,6 +250,9 @@ actor GatewayEndpointStore { let bind = GatewayEndpointStore.resolveGatewayBindMode( root: ClawdbotConfigFile.loadDict(), env: ProcessInfo.processInfo.environment) + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: ClawdbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil) let token = deps.token() let password = deps.password() @@ -257,7 +260,7 @@ actor GatewayEndpointStore { case .local: self.state = .ready( mode: .local, - url: URL(string: "ws://\(host):\(port)")!, + url: URL(string: "\(scheme)://\(host):\(port)")!, token: token, password: password) case .remote: @@ -294,9 +297,12 @@ actor GatewayEndpointStore { self.cancelRemoteEnsure() let port = self.deps.localPort() let host = await self.deps.localHost() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: ClawdbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) self.setState(.ready( mode: .local, - url: URL(string: "ws://\(host):\(port)")!, + url: URL(string: "\(scheme)://\(host):\(port)")!, token: token, password: password)) case .remote: @@ -307,9 +313,12 @@ actor GatewayEndpointStore { return } self.cancelRemoteEnsure() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: ClawdbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) self.setState(.ready( mode: .remote, - url: URL(string: "ws://127.0.0.1:\(Int(port))")!, + url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, token: token, password: password)) case .unconfigured: @@ -478,6 +487,24 @@ actor GatewayEndpointStore { return nil } + private static func resolveGatewayScheme( + root: [String: Any], + env: [String: String]) -> String + { + if let envValue = env["CLAWDBOT_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !envValue.isEmpty + { + return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws" + } + if let gateway = root["gateway"] as? [String: Any], + let tls = gateway["tls"] as? [String: Any], + let enabled = tls["enabled"] as? Bool + { + return enabled ? "wss" : "ws" + } + return "ws" + } + private static func resolveLocalGatewayHost( bindMode: String?, tailscaleIP: String?) -> String diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 69a0392ed..d35796361 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -127,6 +127,8 @@ export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; }; +export type GatewayTlsConfig = BridgeTlsConfig; + export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; @@ -151,5 +153,6 @@ export type GatewayConfig = { tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; reload?: GatewayReloadConfig; + tls?: GatewayTlsConfig; http?: GatewayHttpConfig; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 77eda3c4b..253ac0148 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -300,6 +300,15 @@ export const ClawdbotSchema = z }) .strict() .optional(), + tls: z + .object({ + enabled: z.boolean().optional(), + autoGenerate: z.boolean().optional(), + certPath: z.string().optional(), + keyPath: z.string().optional(), + caPath: z.string().optional(), + }) + .optional(), http: z .object({ endpoints: z diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 1882c5543..0ccfeaf19 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -56,14 +56,16 @@ export function buildGatewayConnectionDetails( options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode ? config.gateway?.remote : undefined; + const tlsEnabled = config.gateway?.tls?.enabled === true; const localPort = resolveGatewayPort(config); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; const preferTailnet = bindMode === "auto" && !!tailnetIPv4; + const scheme = tlsEnabled ? "wss" : "ws"; const localUrl = preferTailnet && tailnetIPv4 - ? `ws://${tailnetIPv4}:${localPort}` - : `ws://127.0.0.1:${localPort}`; + ? `${scheme}://${tailnetIPv4}:${localPort}` + : `${scheme}://127.0.0.1:${localPort}`; const urlOverride = typeof options.url === "string" && options.url.trim().length > 0 ? options.url.trim() diff --git a/src/gateway/server-bridge-runtime.ts b/src/gateway/server-bridge-runtime.ts index ac3bb796a..1cf9c07d0 100644 --- a/src/gateway/server-bridge-runtime.ts +++ b/src/gateway/server-bridge-runtime.ts @@ -37,6 +37,7 @@ export type GatewayBridgeRuntime = { export async function startGatewayBridgeRuntime(params: { cfg: ClawdbotConfig; port: number; + gatewayTls?: { enabled: boolean; fingerprintSha256?: string }; canvasHostEnabled: boolean; canvasHost: CanvasHostHandler | null; canvasRuntime: RuntimeEnv; @@ -221,6 +222,7 @@ export async function startGatewayBridgeRuntime(params: { const discovery = await startGatewayDiscovery({ machineDisplayName: params.machineDisplayName, port: params.port, + gatewayTls: params.gatewayTls, bridgePort: bridge?.port, bridgeTls: bridgeTls.enabled ? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 } diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index 22f56621f..1ca9863d5 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -10,6 +10,7 @@ import { export async function startGatewayDiscovery(params: { machineDisplayName: string; port: number; + gatewayTls?: { enabled: boolean; fingerprintSha256?: string }; bridgePort?: number; bridgeTls?: { enabled: boolean; fingerprintSha256?: string }; canvasPort?: number; @@ -31,6 +32,8 @@ export async function startGatewayDiscovery(params: { const bonjour = await startGatewayBonjourAdvertiser({ instanceName: formatBonjourInstanceName(params.machineDisplayName), gatewayPort: params.port, + gatewayTlsEnabled: params.gatewayTls?.enabled ?? false, + gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256, bridgePort: params.bridgePort, canvasPort: params.canvasPort, bridgeTlsEnabled: params.bridgeTls?.enabled ?? false, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 1f3b89bfa..52dec48f9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -4,6 +4,8 @@ import { type IncomingMessage, type ServerResponse, } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; @@ -193,6 +195,7 @@ export function createGatewayHttpServer(opts: { handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; resolvedAuth: import("./auth.js").ResolvedGatewayAuth; + tlsOptions?: TlsOptions; }): HttpServer { const { canvasHost, @@ -203,11 +206,19 @@ export function createGatewayHttpServer(opts: { handlePluginRequest, resolvedAuth, } = opts; - const httpServer: HttpServer = createHttpServer((req, res) => { + const httpServer: HttpServer = opts.tlsOptions + ? createHttpsServer(opts.tlsOptions, (req, res) => { + void handleRequest(req, res); + }) + : createHttpServer((req, res) => { + void handleRequest(req, res); + }); + + async function handleRequest(req: IncomingMessage, res: ServerResponse) { // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; - void (async () => { + try { if (await handleHooksRequest(req, res)) return; if (await handleSlackHttpRequest(req, res)) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return; @@ -230,12 +241,12 @@ export function createGatewayHttpServer(opts: { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); - })().catch((err) => { + } catch (err) { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(String(err)); - }); - }); + } + } return httpServer; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 60dc4fed5..43e82c759 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -18,6 +18,7 @@ import { MAX_PAYLOAD_BYTES } from "./server-constants.js"; import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import type { DedupeEntry } from "./server-shared.js"; import type { PluginRegistry } from "../plugins/registry.js"; +import type { GatewayTlsRuntime } from "./server/tls.js"; export async function createGatewayRuntimeState(params: { cfg: import("../config/config.js").ClawdbotConfig; @@ -27,6 +28,7 @@ export async function createGatewayRuntimeState(params: { controlUiBasePath: string; openAiChatCompletionsEnabled: boolean; resolvedAuth: ResolvedGatewayAuth; + gatewayTls?: GatewayTlsRuntime; hooksConfig: () => HooksConfigResolved | null; pluginRegistry: PluginRegistry; deps: CliDeps; @@ -104,6 +106,7 @@ export async function createGatewayRuntimeState(params: { handleHooksRequest, handlePluginRequest, resolvedAuth: params.resolvedAuth, + tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, }); await listenGatewayHttpServer({ diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index 2948bd7dd..59bb81663 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -8,6 +8,7 @@ export function logGatewayStartup(params: { cfg: ReturnType; bindHost: string; port: number; + tlsEnabled?: boolean; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; }) { @@ -20,7 +21,8 @@ export function logGatewayStartup(params: { params.log.info(`agent model: ${modelRef}`, { consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, }); - params.log.info(`listening on ws://${params.bindHost}:${params.port} (PID ${process.pid})`); + const scheme = params.tlsEnabled ? "wss" : "ws"; + params.log.info(`listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`); params.log.info(`log file: ${getResolvedLoggerSettings().file}`); if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a7c406dc0..b67ae15e2 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -58,6 +58,7 @@ import { resolveSessionKeyForRun } from "./server-session-key.js"; import { startGatewaySidecars } from "./server-startup.js"; import { logGatewayStartup } from "./server-startup-log.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; +import { loadGatewayTlsRuntime } from "./server/tls.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; import { attachGatewayWsHandlers } from "./server-ws-runtime.js"; @@ -222,6 +223,10 @@ export async function startGatewayServer( const deps = createDefaultDeps(); let canvasHostServer: CanvasHostServer | null = null; + const gatewayTls = await loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls")); + if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) { + throw new Error(gatewayTls.error ?? "gateway tls: failed to enable"); + } const { canvasHost, httpServer, @@ -244,6 +249,7 @@ export async function startGatewayServer( controlUiBasePath, openAiChatCompletionsEnabled, resolvedAuth, + gatewayTls, hooksConfig: () => hooksConfig, pluginRegistry, deps, @@ -279,6 +285,9 @@ export async function startGatewayServer( const bridgeRuntime = await startGatewayBridgeRuntime({ cfg: cfgAtStart, port, + gatewayTls: gatewayTls.enabled + ? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 } + : undefined, canvasHostEnabled, canvasHost, canvasRuntime, @@ -412,6 +421,7 @@ export async function startGatewayServer( cfg: cfgAtStart, bindHost, port, + tlsEnabled: gatewayTls.enabled, log, isNixMode, }); diff --git a/src/gateway/server/tls.ts b/src/gateway/server/tls.ts new file mode 100644 index 000000000..48daa90ac --- /dev/null +++ b/src/gateway/server/tls.ts @@ -0,0 +1,14 @@ +import type { BridgeTlsConfig } from "../../config/types.gateway.js"; +import { + type BridgeTlsRuntime, + loadBridgeTlsRuntime, +} from "../../infra/bridge/server/tls.js"; + +export type GatewayTlsRuntime = BridgeTlsRuntime; + +export async function loadGatewayTlsRuntime( + cfg: BridgeTlsConfig | undefined, + log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, +): Promise { + return await loadBridgeTlsRuntime(cfg, log); +} diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 54be2c9ba..3eae92732 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -15,6 +15,8 @@ export type GatewayBonjourAdvertiseOpts = { instanceName?: string; gatewayPort: number; sshPort?: number; + gatewayTlsEnabled?: boolean; + gatewayTlsFingerprintSha256?: string; bridgePort?: number; canvasPort?: number; bridgeTlsEnabled?: boolean; @@ -107,6 +109,12 @@ export async function startGatewayBonjourAdvertiser( if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) { txtBase.bridgePort = String(opts.bridgePort); } + if (opts.gatewayTlsEnabled) { + txtBase.gatewayTls = "1"; + if (opts.gatewayTlsFingerprintSha256) { + txtBase.gatewayTlsSha256 = opts.gatewayTlsFingerprintSha256; + } + } if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) { txtBase.canvasPort = String(opts.canvasPort); }