feat: add TLS for node bridge
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
152
src/infra/bridge/server/tls.ts
Normal file
152
src/infra/bridge/server/tls.ts
Normal 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)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user