feat: add gateway tls support

This commit is contained in:
Peter Steinberger
2026-01-19 02:46:07 +00:00
parent 73e9e787b4
commit b347d5d9cc
12 changed files with 105 additions and 11 deletions

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -8,6 +8,7 @@ export function logGatewayStartup(params: {
cfg: ReturnType<typeof loadConfig>;
bindHost: string;
port: number;
tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => 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)");

View File

@@ -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,
});

14
src/gateway/server/tls.ts Normal file
View File

@@ -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<GatewayTlsRuntime> {
return await loadBridgeTlsRuntime(cfg, log);
}

View File

@@ -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);
}