feat: add TLS for node bridge

This commit is contained in:
Peter Steinberger
2026-01-16 05:28:33 +00:00
parent 1656f491fd
commit 1ab1e312b2
36 changed files with 1161 additions and 180 deletions

View File

@@ -11,6 +11,20 @@ export type BridgeConfig = {
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
*/
bind?: BridgeBindMode;
tls?: BridgeTlsConfig;
};
export type BridgeTlsConfig = {
/** Enable TLS for the node bridge server. */
enabled?: boolean;
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
autoGenerate?: boolean;
/** PEM certificate path for the bridge server. */
certPath?: string;
/** PEM private key path for the bridge server. */
keyPath?: string;
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
caPath?: string;
};
export type WideAreaDiscoveryConfig = {

View File

@@ -171,6 +171,15 @@ export const ClawdbotSchema = z
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.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(),
})
.optional(),
discovery: z

View File

@@ -6,6 +6,7 @@ import type { HealthSummary } from "../commands/health.js";
import type { ClawdbotConfig } from "../config/config.js";
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
@@ -71,7 +72,7 @@ export async function startGatewayBridgeRuntime(params: {
}): Promise<GatewayBridgeRuntime> {
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
const bridgeEnabled = (() => {
let bridgeEnabled = (() => {
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
})();
@@ -111,6 +112,14 @@ export async function startGatewayBridgeRuntime(params: {
return "0.0.0.0";
})();
const bridgeTls = bridgeEnabled
? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge)
: { enabled: false, required: false };
if (bridgeTls.required && !bridgeTls.enabled) {
params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled");
bridgeEnabled = false;
}
const canvasHostPort = (() => {
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
@@ -197,6 +206,7 @@ export async function startGatewayBridgeRuntime(params: {
bridgeEnabled,
bridgePort,
bridgeHost,
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
machineDisplayName: params.machineDisplayName,
canvasHostPort: canvasHostPortForBridge,
canvasHostHost: canvasHostHostForBridge,
@@ -212,6 +222,9 @@ export async function startGatewayBridgeRuntime(params: {
machineDisplayName: params.machineDisplayName,
port: params.port,
bridgePort: bridge?.port,
bridgeTls: bridgeTls.enabled
? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 }
: undefined,
canvasPort: canvasHostPortForBridge,
wideAreaDiscoveryEnabled,
logDiscovery: params.logDiscovery,

View File

@@ -11,6 +11,7 @@ export async function startGatewayDiscovery(params: {
machineDisplayName: string;
port: number;
bridgePort?: number;
bridgeTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
@@ -27,6 +28,8 @@ export async function startGatewayDiscovery(params: {
gatewayPort: params.port,
bridgePort: params.bridgePort,
canvasPort: params.canvasPort,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
sshPort,
tailnetDns,
cliPath: resolveBonjourCliPath(),
@@ -51,6 +54,8 @@ export async function startGatewayDiscovery(params: {
displayName: formatBonjourInstanceName(params.machineDisplayName),
tailnetIPv4,
tailnetIPv6: tailnetIPv6 ?? undefined,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
tailnetDns,
sshPort,
cliPath: resolveBonjourCliPath(),

View File

@@ -1,5 +1,6 @@
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import type { ClawdbotConfig } from "../config/config.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
@@ -23,6 +24,7 @@ export async function startGatewayNodeBridge(params: {
bridgeEnabled: boolean;
bridgePort: number;
bridgeHost: string | null;
bridgeTls?: BridgeTlsRuntime;
machineDisplayName: string;
canvasHostPort?: number;
canvasHostHost?: string;
@@ -111,6 +113,7 @@ export async function startGatewayNodeBridge(params: {
const started = await startNodeBridgeServer({
host: params.bridgeHost,
port: params.bridgePort,
tls: params.bridgeTls?.tlsOptions,
serverName: params.machineDisplayName,
canvasHostPort: params.canvasHostPort,
canvasHostHost: params.canvasHostHost,
@@ -158,7 +161,8 @@ export async function startGatewayNodeBridge(params: {
},
});
if (started.port > 0) {
params.logBridge.info(`listening on tcp://${params.bridgeHost}:${started.port} (node)`);
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`);
return { bridge: started, nodePresenceTimers };
}
} catch (err) {

View File

@@ -12,6 +12,8 @@ export type GatewayBonjourBeacon = {
bridgePort?: number;
gatewayPort?: number;
sshPort?: number;
bridgeTls?: boolean;
bridgeTlsFingerprintSha256?: string;
cliPath?: string;
txt?: Record<string, string>;
};
@@ -206,6 +208,11 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
beacon.sshPort = parseIntOrNull(txt.sshPort);
if (txt.bridgeTls) {
const raw = txt.bridgeTls.trim().toLowerCase();
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
return beacon;

View File

@@ -16,6 +16,8 @@ export type GatewayBonjourAdvertiseOpts = {
sshPort?: number;
bridgePort?: number;
canvasPort?: number;
bridgeTlsEnabled?: boolean;
bridgeTlsFingerprintSha256?: string;
tailnetDns?: string;
cliPath?: string;
};
@@ -107,6 +109,12 @@ export async function startGatewayBonjourAdvertiser(
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
txtBase.canvasPort = String(opts.canvasPort);
}
if (opts.bridgeTlsEnabled) {
txtBase.bridgeTls = "1";
if (opts.bridgeTlsFingerprintSha256) {
txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256;
}
}
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = opts.tailnetDns.trim();
}

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import net from "node:net";
import os from "node:os";
import tls from "node:tls";
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
@@ -47,7 +48,8 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
const loopbackHost = "127.0.0.1";
const listeners: Array<{ host: string; server: net.Server }> = [];
const primary = net.createServer(onConnection);
const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection));
const primary = createServer();
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);
primary.once("error", onError);
@@ -65,7 +67,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
const port = typeof address === "object" && address ? address.port : opts.port;
if (shouldAlsoListenOnLoopback(opts.host)) {
const loopback = net.createServer(onConnection);
const loopback = createServer();
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);

View File

@@ -0,0 +1,152 @@
import { execFile } from "node:child_process";
import { X509Certificate } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import tls from "node:tls";
import { promisify } from "node:util";
import type { BridgeTlsConfig } from "../../../config/types.gateway.js";
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js";
const execFileAsync = promisify(execFile);
export type BridgeTlsRuntime = {
enabled: boolean;
required: boolean;
certPath?: string;
keyPath?: string;
caPath?: string;
fingerprintSha256?: string;
tlsOptions?: tls.TlsOptions;
error?: string;
};
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function generateSelfSignedCert(params: {
certPath: string;
keyPath: string;
log?: { info?: (msg: string) => void };
}): Promise<void> {
const certDir = path.dirname(params.certPath);
const keyDir = path.dirname(params.keyPath);
await ensureDir(certDir);
if (keyDir !== certDir) {
await ensureDir(keyDir);
}
await execFileAsync("openssl", [
"req",
"-x509",
"-newkey",
"rsa:2048",
"-sha256",
"-days",
"3650",
"-nodes",
"-keyout",
params.keyPath,
"-out",
params.certPath,
"-subj",
"/CN=clawdbot-bridge",
]);
await fs.chmod(params.keyPath, 0o600).catch(() => {});
await fs.chmod(params.certPath, 0o600).catch(() => {});
params.log?.info?.(
`bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
);
}
export async function loadBridgeTlsRuntime(
cfg: BridgeTlsConfig | undefined,
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
): Promise<BridgeTlsRuntime> {
if (!cfg || cfg.enabled !== true) return { enabled: false, required: false };
const autoGenerate = cfg.autoGenerate !== false;
const baseDir = path.join(CONFIG_DIR, "bridge", "tls");
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem"));
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem"));
const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined;
const hasCert = await fileExists(certPath);
const hasKey = await fileExists(keyPath);
if (!hasCert && !hasKey && autoGenerate) {
try {
await generateSelfSignedCert({ certPath, keyPath, log });
} catch (err) {
return {
enabled: false,
required: true,
certPath,
keyPath,
error: `bridge tls: failed to generate cert (${String(err)})`,
};
}
}
if (!(await fileExists(certPath)) || !(await fileExists(keyPath))) {
return {
enabled: false,
required: true,
certPath,
keyPath,
error: "bridge tls: cert/key missing",
};
}
try {
const cert = await fs.readFile(certPath, "utf8");
const key = await fs.readFile(keyPath, "utf8");
const ca = caPath ? await fs.readFile(caPath, "utf8") : undefined;
const x509 = new X509Certificate(cert);
const fingerprintSha256 = normalizeFingerprint(x509.fingerprint256 ?? "");
if (!fingerprintSha256) {
return {
enabled: false,
required: true,
certPath,
keyPath,
caPath,
error: "bridge tls: unable to compute certificate fingerprint",
};
}
return {
enabled: true,
required: true,
certPath,
keyPath,
caPath,
fingerprintSha256,
tlsOptions: {
cert,
key,
ca,
minVersion: "TLSv1.2",
},
};
} catch (err) {
return {
enabled: false,
required: true,
certPath,
keyPath,
caPath,
error: `bridge tls: failed to load cert (${String(err)})`,
};
}
}

View File

@@ -1,3 +1,5 @@
import type { TlsOptions } from "node:tls";
import type { NodePairingPendingRequest } from "../../node-pairing.js";
export type BridgeHelloFrame = {
@@ -122,6 +124,7 @@ export type NodeBridgeClientInfo = {
export type NodeBridgeServerOpts = {
host: string;
port: number; // 0 = ephemeral
tls?: TlsOptions;
pairingBaseDir?: string;
canvasHostPort?: number;
canvasHostHost?: string;

View File

@@ -73,7 +73,17 @@ async function writeJSONAtomic(filePath: string, value: unknown) {
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
try {
await fs.chmod(tmp, 0o600);
} catch {
// best-effort; ignore on platforms without chmod
}
await fs.rename(tmp, filePath);
try {
await fs.chmod(filePath, 0o600);
} catch {
// best-effort; ignore on platforms without chmod
}
}
function pruneExpiredPending(

View File

@@ -71,6 +71,8 @@ export type WideAreaBridgeZoneOpts = {
displayName: string;
tailnetIPv4: string;
tailnetIPv6?: string;
bridgeTlsEnabled?: boolean;
bridgeTlsFingerprintSha256?: string;
instanceLabel?: string;
hostLabel?: string;
tailnetDns?: string;
@@ -91,6 +93,12 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) {
txt.push(`gatewayPort=${opts.gatewayPort}`);
}
if (opts.bridgeTlsEnabled) {
txt.push(`bridgeTls=1`);
if (opts.bridgeTlsFingerprintSha256) {
txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`);
}
}
if (opts.tailnetDns?.trim()) {
txt.push(`tailnetDns=${opts.tailnetDns.trim()}`);
}