UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,097 lines (1,096 loc) • 39 kB
import { t as normalizeExactAllowedHost } from "./exact-hostname-B5MIU7_E.mjs"; import { a as containsSecretSentinel, i as SECRET_SENTINEL_SUFFIX, l as resolveSecretSentinel, n as SECRET_SENTINEL_PATTERN, r as SECRET_SENTINEL_PREFIX, s as looksLikeSecretSentinel } from "./sentinel-BZ9NeZRm.mjs"; import { n as ensureSecretEgressProxyCa, r as generateLocalProxyLeaf } from "./ca-Ccx4iT7M.mjs"; import fs from "node:fs"; import { URL } from "node:url"; import path from "node:path"; import net from "node:net"; import { X509Certificate, randomBytes, timingSafeEqual } from "node:crypto"; import { PassThrough, Readable, Transform, Writable } from "node:stream"; import { ServerResponse, createServer as createServer$1 } from "node:http"; import { Agent as Agent$1, request as request$1 } from "node:https"; import { createSecureContext, createServer as createServer$3, rootCertificates } from "node:tls"; //#region src/secrets/egress-proxy/certificates.ts const LEAF_RENEWAL_MARGIN_MS = 36e5; const CA_WARNING_MARGIN_MS = 6048e5; const CERTIFICATE_RETRY_MESSAGE = "Secret egress proxy could not prepare a TLS certificate. Check Gateway logs, OpenSSL availability, and the system clock, then retry the request."; const CA_RESTART_MESSAGE = "Secret egress proxy CA is expired or not yet valid. Check the system clock, then restart the Gateway to create a new process trust chain."; var SecretEgressCertificateError = class extends Error {}; function readValidity(pem) { const certificate = new X509Certificate(pem); return { notBefore: certificate.validFromDate.getTime(), notAfter: certificate.validToDate.getTime() }; } function validAt(validity, now) { return validity.notBefore <= now && now < validity.notAfter; } /** Owns one process trust chain and the certificate work using its private key. */ async function createSecretEgressCertificates(certDir) { const ca = await ensureSecretEgressProxyCa(certDir); const caPem = fs.readFileSync(ca.certPath, "utf8"); const caValidity = readValidity(caPem); const caExpiresAt = new Date(caValidity.notAfter).toISOString(); const preparations = /* @__PURE__ */ new Set(); let failedCertificates = 0; const assertCaValid = () => { if (!validAt(caValidity, Date.now())) throw new SecretEgressCertificateError(CA_RESTART_MESSAGE); }; return { caCertPath: ca.certPath, caPem, getStatus: () => { const now = Date.now(); const message = !validAt(caValidity, now) ? CA_RESTART_MESSAGE : failedCertificates > 0 ? CERTIFICATE_RETRY_MESSAGE : caValidity.notAfter - now <= CA_WARNING_MARGIN_MS ? "Secret egress proxy CA expires within seven days. Restart the Gateway before expiry to create a new process trust chain." : void 0; return { state: message ? "degraded" : "ready", caExpiresAt, failedCertificates, ...message ? { message } : {} }; }, createContext: (params) => { let ready; let preparation; let failed = false; const setFailed = (value) => { failedCertificates += Number(value) - Number(failed); failed = value; }; return { get: async () => { if (!params.isActive()) return; assertCaValid(); if (preparation) return preparation; const now = Date.now(); if (ready && validAt(ready.validity, now) && ready.validity.notAfter - now > LEAF_RENEWAL_MARGIN_MS) return ready.server; const pending = generateLocalProxyLeaf({ certDir, ca, hostname: params.hostname }).then((leaf) => { if (!params.isActive()) return; assertCaValid(); const validity = readValidity(leaf.cert); if (!validAt(validity, Date.now())) throw new SecretEgressCertificateError(CERTIFICATE_RETRY_MESSAGE); if (ready) { ready.server.setSecureContext(leaf); ready.validity = validity; } else ready = { server: params.createServer(leaf), validity }; setFailed(false); return ready.server; }).catch(() => { if (!params.isActive()) return; setFailed(true); assertCaValid(); throw new SecretEgressCertificateError(CERTIFICATE_RETRY_MESSAGE); }); preparation = pending; preparations.add(pending); try { return await pending; } finally { preparation = void 0; preparations.delete(pending); } }, close: () => { setFailed(false); ready?.server.close(); } }; }, waitForPreparations: () => Promise.allSettled(preparations) }; } //#endregion //#region src/secrets/egress-proxy/response-headers.ts const ASCII_HEADER_VALUE = /^[\t\x20-\x7e]*$/; const DISPOSITION_PARAM = /;\s*([^\s=;]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;]*)/g; const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); /** * Makes upstream response headers safe to hand to `ServerResponse.writeHead`. * * Node exposes received header bytes as latin1 strings and forwards them as-is, * except Content-Disposition: once Content-Length is stored, Node revalidates it * as UTF-8 and throws ERR_INVALID_CHAR for non-ASCII filename bytes. Only that * header is rewritten; RFC 6266 `filename*` keeps the exact name. */ function toForwardableResponseHeaders(headers) { const disposition = headers["content-disposition"]; if (disposition === void 0 || ASCII_HEADER_VALUE.test(disposition)) return headers; return { ...headers, "content-disposition": toAsciiContentDisposition(disposition) }; } function toAsciiContentDisposition(value) { const text = decodeReceivedHeaderValue(value); const type = text.split(";", 1)[0]?.trim() ?? ""; if (!ASCII_HEADER_VALUE.test(type) || !type) return toAsciiFallback(text); const params = []; let filename; let extendedFilename; for (const [, rawName = "", rawValue = ""] of text.matchAll(DISPOSITION_PARAM)) { const name = rawName.toLowerCase(); const paramValue = rawValue.trim(); if (name === "filename") filename = unquote(paramValue); else if (name === "filename*" && ASCII_HEADER_VALUE.test(paramValue)) extendedFilename = paramValue; else if (ASCII_HEADER_VALUE.test(paramValue)) params.push(`${rawName}=${paramValue}`); } if (filename !== void 0) { params.push(`filename="${toAsciiFallback(filename).replace(/[%"\\]/g, "_")}"`); extendedFilename ??= ASCII_HEADER_VALUE.test(filename) ? void 0 : `UTF-8''${encodeRfc8187(filename)}`; } if (extendedFilename !== void 0) params.push(`filename*=${extendedFilename}`); return [type, ...params].join("; "); } /** Recovers UTF-8 text from latin1-decoded wire bytes, keeping other values as received. */ function decodeReceivedHeaderValue(value) { for (let index = 0; index < value.length; index++) if (value.charCodeAt(index) > 255) return value; try { return utf8Decoder.decode(Buffer.from(value, "latin1")); } catch { return value; } } function unquote(value) { return value.startsWith("\"") && value.endsWith("\"") && value.length >= 2 ? value.slice(1, -1).replace(/\\(.)/g, "$1") : value; } function encodeRfc8187(value) { return encodeURIComponent(value.replace(/\p{Surrogate}/gu, "�")).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); } function toAsciiFallback(value) { return Array.from(value, (char) => ASCII_HEADER_VALUE.test(char) ? char : "_").join(""); } //#endregion //#region src/secrets/egress-proxy/stream-substitution.ts const SENTINEL_PREFIX_BYTES = Buffer.from(SECRET_SENTINEL_PREFIX); const SENTINEL_SUFFIX_BYTES = Buffer.from(SECRET_SENTINEL_SUFFIX); var SecretEgressSubstitutionError = class extends Error { constructor(reason, details) { super(details ? `Secret "${details.secretName}" is not allowed for host "${details.host}". Run: openclaw secrets store set ${details.secretName} --allow-host ${details.host}` : "Secret egress proxy refused an unresolved secret sentinel"); this.reason = reason; this.details = details; this.name = "SecretEgressSubstitutionError"; } }; function processPendingBuffer(params) { let pending = params.buffer; for (;;) { const prefixIndex = pending.indexOf(SENTINEL_PREFIX_BYTES); if (prefixIndex === -1) { const carryBytes = params.flush ? 0 : Math.min(pending.length, SENTINEL_PREFIX_BYTES.length - 1); const emitBytes = pending.length - carryBytes; if (emitBytes > 0) params.push(pending.subarray(0, emitBytes)); return carryBytes > 0 ? pending.subarray(emitBytes) : Buffer.alloc(0); } if (prefixIndex > 0) { params.push(pending.subarray(0, prefixIndex)); pending = pending.subarray(prefixIndex); } const suffixIndex = pending.indexOf(SENTINEL_SUFFIX_BYTES, SENTINEL_PREFIX_BYTES.length); if (suffixIndex === -1) { if (params.flush || pending.length > 87445) throw new SecretEgressSubstitutionError("unresolved-sentinel"); return pending; } const sentinelEnd = suffixIndex + SENTINEL_SUFFIX_BYTES.length; if (sentinelEnd > 87445) throw new SecretEgressSubstitutionError("unresolved-sentinel"); const sentinel = pending.subarray(0, sentinelEnd).toString("ascii"); const resolved = looksLikeSecretSentinel(sentinel) ? params.resolveSentinel(sentinel) : void 0; if (resolved === void 0) throw new SecretEgressSubstitutionError("unresolved-sentinel"); if (Buffer.byteLength(resolved, "utf8") > sentinelEnd) throw new SecretEgressSubstitutionError("unresolved-sentinel"); params.push(Buffer.from(resolved, "utf8")); params.onSubstitution(); pending = pending.subarray(sentinelEnd); } } /** Reuses the binary scanner in place; nonexpansion keeps writes behind unread input. */ function substituteSecretEgressBody(buffer, params) { let length = 0; processPendingBuffer({ ...params, buffer, flush: true, push: (chunk) => { chunk.copy(buffer, length); length += chunk.length; } }); return buffer.subarray(0, length); } /** Rewrites process-local sentinels across arbitrary request-body chunk boundaries. */ function createSecretEgressBodyTransform(params) { let pending = Buffer.alloc(0); const push = (stream, output) => { if (params.isActive && !params.isActive()) throw new SecretEgressSubstitutionError("unresolved-sentinel"); stream.push(output); }; return new Transform({ transform(chunk, _encoding, callback) { try { const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); pending = processPendingBuffer({ buffer: pending.length > 0 ? Buffer.concat([pending, input]) : input, flush: false, onSubstitution: params.onSubstitution, resolveSentinel: params.resolveSentinel, push: (output) => push(this, output) }); callback(); } catch (error) { callback(error); } }, destroy(error, callback) { pending = Buffer.alloc(0); callback(error); }, flush(callback) { try { pending = processPendingBuffer({ buffer: pending, flush: true, onSubstitution: params.onSubstitution, resolveSentinel: params.resolveSentinel, push: (output) => push(this, output) }); callback(); } catch (error) { callback(error); } } }); } //#endregion //#region src/secrets/egress-proxy/proxy-forward.ts const REFUSAL_BODY = "Secret egress proxy refused the request.\n"; const UPSTREAM_ERROR_BODY = "Secret egress proxy could not reach the upstream host.\n"; const UPSTREAM_RESPONSE_ERROR_BODY = "Secret egress proxy could not forward the upstream response.\n"; const MAX_BUFFERED_REQUEST_BODY_BYTES = 104857600; const BUFFERED_REQUEST_WRITE_BYTES = 65536; const MAX_BUFFERED_UPGRADE_BYTES = 65536; function sendHttpRefusal(res, status = 502, body = REFUSAL_BODY) { if (res.destroyed || res.writableEnded) return; if (res.headersSent) { res.destroy(); return; } res.writeHead(status, { Connection: "close", "Content-Length": Buffer.byteLength(body), "Content-Type": "text/plain; charset=utf-8" }); res.end(body); } function handleUpgradeRequest(handler, request, head) { const response = new ServerResponse(request); try { response.assignSocket(request.socket); } catch { request.socket.destroy(); return; } const stream = new PassThrough({ highWaterMark: MAX_BUFFERED_UPGRADE_BYTES }); let buffering = true; const stopBuffering = () => { if (!buffering) return; buffering = false; request.socket.pause(); request.socket.off("data", onData); request.socket.off("end", onEnd); }; const onData = (chunk) => { if (!stream.write(chunk)) { stopBuffering(); response.destroy(); request.socket.destroy(); } }; const onEnd = () => stream.end(); request.socket.on("data", onData); request.socket.once("end", onEnd); request.socket.once("close", () => { stopBuffering(); stream.destroy(); if (!response.writableEnded) response.destroy(); }); response.once("finish", () => { if (response.socket) { stream.destroy(); response.socket.end(); } }); if (head.length > 0) onData(head); if (request.socket.destroyed) return; handler(request, response, { stream, stopBuffering }); } function sendSecretEgressRequest(forward, body, releaseBody) { const { target, headers, host } = forward; let { substituted } = forward; delete headers["content-length"]; delete headers["transfer-encoding"]; if (body !== void 0) { headers["content-length"] = String(body.length); delete headers.expect; delete headers.trailer; } let refused = false; let upgraded = false; const upstream = forward.ownResource(request$1({ hostname: target.hostname, port: target.port || 443, path: `${target.pathname}${target.search}`, method: forward.request.method, headers, agent: forward.upstreamTlsAgent }, (upstreamResponse) => { forward.ownResource(upstreamResponse); if (refused || !forward.isActive()) { upstreamResponse.destroy(); return; } upstreamResponse.once("error", () => forward.response.destroy()); const statusCode = upstreamResponse.statusCode ?? 502; try { forward.response.writeHead(statusCode, toForwardableResponseHeaders(upstreamResponse.headers)); } catch { refused = true; upstreamResponse.destroy(); if (statusCode < 200 || statusCode === 204 || statusCode === 304) forward.response.destroy(); else sendHttpRefusal(forward.response, 502, UPSTREAM_RESPONSE_ERROR_BODY); return; } upstreamResponse.pipe(forward.response); })); upstream.once("socket", () => { if (!forward.isActive()) { upstream.destroy(); forward.response.destroy(); } }); const bodyTransform = forward.ownResource(createSecretEgressBodyTransform({ isActive: forward.isActive, onSubstitution: () => { substituted = true; }, resolveSentinel: forward.resolveSentinel })); forward.request.once("error", () => forward.response.destroy()); const onResponseClose = () => { refused = true; forward.request.unpipe(bodyTransform); bodyTransform.destroy(); upstream.destroy(); }; forward.response.once("close", onResponseClose); if (forward.upgrade) forward.request.socket.once("end", () => { if (!upgraded) forward.response.destroy(); }); upstream.once("close", () => releaseBody?.()); upstream.once("finish", () => { releaseBody?.(); if (!refused && forward.isActive()) forward.audit({ kind: "forwarded", host, substituted }); }); bodyTransform.once("error", (error) => { if (refused || !forward.isActive()) return; refused = true; forward.request.unpipe(bodyTransform); forward.request.resume(); upstream.destroy(); const reason = error instanceof SecretEgressSubstitutionError ? error.reason : "unresolved-sentinel"; forward.audit({ kind: "refused", host, substituted, reason }); sendHttpRefusal(forward.response, 502, error instanceof SecretEgressSubstitutionError ? `${error.message}\n` : REFUSAL_BODY); }); upstream.once("error", () => { if (refused || !forward.isActive()) return; refused = true; forward.audit({ kind: "refused", host, substituted, reason: "upstream-error" }); sendHttpRefusal(forward.response, 502, UPSTREAM_ERROR_BODY); }); upstream.once("upgrade", (response, upstreamSocket, head) => { forward.ownResource(upstreamSocket); if (refused || !forward.isActive()) { upstreamSocket.destroy(); return; } if (!forward.upgrade || response.statusCode !== 101 || response.headers.upgrade?.toLowerCase() !== "websocket") { refused = true; forward.audit({ kind: "refused", host, substituted, reason: "upstream-error" }); upstreamSocket.destroy(); sendHttpRefusal(forward.response); return; } const clientSocket = forward.ownResource(forward.request.socket); forward.response.off("close", onResponseClose); upgraded = true; forward.response.writeHead(101, toForwardableResponseHeaders(response.headers)); forward.response.end(); forward.response.detachSocket(clientSocket); forward.releaseResponse(); bodyTransform.destroy(); clientSocket.once("close", () => upstreamSocket.destroy()); upstreamSocket.once("close", () => clientSocket.destroy()); if (head.length > 0) clientSocket.write(head); const bufferedClientFrames = forward.upgrade.stream; forward.upgrade.stopBuffering(); bufferedClientFrames.once("end", () => { if (!clientSocket.destroyed && !upstreamSocket.destroyed) clientSocket.pipe(upstreamSocket); }); bufferedClientFrames.pipe(upstreamSocket, { end: false }); bufferedClientFrames.end(); upstreamSocket.pipe(clientSocket); }); if (forward.upgrade) upstream.end(); else if (body !== void 0) { bodyTransform.destroy(); const bufferedBody = body; let offset = 0; let scheduled; const writeBody = () => { scheduled = void 0; if (refused || !forward.isActive() || upstream.destroyed) { upstream.destroy(); return; } if (offset === bufferedBody.length) { upstream.off("close", stopSending); upstream.end(); return; } const chunk = bufferedBody.subarray(offset, offset + BUFFERED_REQUEST_WRITE_BYTES); offset += chunk.length; if (upstream.write(chunk)) scheduled = setImmediate(writeBody); else upstream.once("drain", writeBody); }; const stopSending = () => { if (scheduled) clearImmediate(scheduled); upstream.off("drain", writeBody); }; upstream.once("close", stopSending); writeBody(); } else forward.request.pipe(bodyTransform).pipe(upstream); return upstream; } /** One proxy owns admission across all registrations, including bodies still being sent. */ function createSecretEgressBodyBudget() { let bytes = 0; let count = 0; return (length) => { const weight = length + 262144; if (count >= 64 || bytes + weight > 134217728) return; bytes += weight; count++; let released = false; return () => { if (!released) { released = true; bytes -= weight; count--; } }; }; } function allocateBufferedRequestBody(length) { if (!Number.isSafeInteger(length) || length < 0 || length > MAX_BUFFERED_REQUEST_BODY_BYTES) throw new RangeError("Invalid buffered request body length"); return Buffer.allocUnsafeSlow(length); } /** Collect original bytes, then authorize/substitute synchronously at the send boundary. */ function forwardSecretEgressRequest(forward) { const length = Number(forward.request.headers["content-length"]); const buffered = !forward.upgrade && forward.request.headers["content-length"] !== void 0 && forward.request.headers["transfer-encoding"] === void 0 && Number.isSafeInteger(length) && length >= 0 && length <= MAX_BUFFERED_REQUEST_BODY_BYTES; let body; let collector; let upstream; const releaseBudget = buffered ? forward.acquireBody(length) : void 0; let refused = false; let substituted = false; const release = () => { body = void 0; clearTimeout(timer); releaseBudget?.(); }; const refuse = (reason, status = 502, message = REFUSAL_BODY) => { if (refused) return; refused = true; if (collector) { forward.request.unpipe(collector); collector.destroy(); } forward.request.resume(); if (upstream) upstream.destroy(); else release(); forward.response.once("close", () => forward.request.destroy()); forward.audit({ kind: "refused", host: forward.host, substituted, reason }); sendHttpRefusal(forward.response, status, message); }; const timer = releaseBudget ? setTimeout(() => refuse("request-timeout", 504, "Secret egress proxy upload timed out.\n"), 3e5) : void 0; timer?.unref(); const send = () => { if (refused || !forward.isActive() || forward.response.destroyed) { release(); return; } try { const prepared = forward.prepareRequest(); substituted = prepared.substituted; const output = body === void 0 ? void 0 : substituteSecretEgressBody(body, { resolveSentinel: forward.resolveSentinel, onSubstitution: () => { substituted = true; } }); if (!forward.isActive()) { release(); return; } upstream = sendSecretEgressRequest({ ...forward, ...prepared, substituted, isActive: () => !refused && forward.isActive() }, output, release); body = void 0; } catch (error) { refuse(error instanceof SecretEgressSubstitutionError ? error.reason : "upstream-error", error instanceof SecretEgressSubstitutionError && error.reason === "host-not-allowed" ? 403 : 502, error instanceof SecretEgressSubstitutionError ? error.message + "\n" : REFUSAL_BODY); } }; if (!buffered) { send(); return; } forward.request.once("error", () => forward.response.destroy()); forward.response.once("close", () => { refused = true; if (collector) { forward.request.unpipe(collector); collector.destroy(); } if (upstream) upstream.destroy(); else release(); }); if (!releaseBudget) { refuse("upload-capacity", 503, "Secret egress proxy upload capacity is busy. Retry the request.\n"); return; } try { body = allocateBufferedRequestBody(length); let received = 0; collector = forward.ownResource(new Writable({ write(chunk, _encoding, callback) { if (!body || received + chunk.length > length) { callback(/* @__PURE__ */ new Error("Invalid request body length")); return; } chunk.copy(body, received); received += chunk.length; callback(); } })); collector.once("error", () => refuse("upstream-error")); collector.once("finish", () => { if (received !== length) refuse("upstream-error"); else send(); }); forward.request.pipe(collector); } catch { refuse("upstream-error"); } } //#endregion //#region src/secrets/egress-proxy/proxy-server.ts const PROXY_AUTH_USERNAME = "openclaw"; const PROXY_AUTH_REALM = "OpenClaw secret egress"; function parseConnectTarget(rawTarget) { const raw = rawTarget?.trim(); if (!raw || /[\r\n]/u.test(raw)) throw new Error("Invalid CONNECT target"); const target = new URL(`https://${raw}`); if (target.pathname !== "/" || target.search || target.hash || target.username || target.password) throw new Error("Invalid CONNECT target"); const port = target.port ? Number(target.port) : 443; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid CONNECT target port"); return { hostname: normalizeExactAllowedHost(target.hostname), port }; } function parseProxyToken(token) { if (!/^[A-Za-z0-9_-]{43}$/u.test(token)) return; const bytes = Buffer.from(token, "base64url"); return bytes.length === 32 && bytes.toString("base64url") === token ? bytes : void 0; } function parseBasicProxyPassword(header) { if (typeof header !== "string") return; const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/iu.exec(header.trim()); if (!match?.[1]) return; let decoded; try { decoded = Buffer.from(match[1], "base64").toString("utf8"); } catch { return; } const colon = decoded.indexOf(":"); if (colon === -1 || decoded.slice(0, colon) !== PROXY_AUTH_USERNAME) return; return decoded.slice(colon + 1); } function sendProxyAuthRequired(socket) { socket.end(`HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="${PROXY_AUTH_REALM}"\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(REFUSAL_BODY)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${REFUSAL_BODY}`); } function resolveRegisteredSentinel(params) { if (!params.registered.isActive()) return; const binding = params.registered.sentinelBindings.get(params.sentinel); if (!binding) return; if (!binding.allowedHosts.has(params.host)) throw new SecretEgressSubstitutionError("destination-not-allowed", { host: params.host, secretName: binding.name }); return params.registered.resolveSentinel(params.sentinel); } function swapRequestText(params) { if (!containsSecretSentinel(params.value)) return { value: params.value, substituted: false }; let substituted = false; const swapped = params.value.replace(new RegExp(SECRET_SENTINEL_PATTERN.source, "g"), (sentinel) => { const resolved = resolveRegisteredSentinel({ sentinel, host: params.host, registered: params.registered }); if (resolved === void 0) return sentinel; substituted = true; return params.urlMode ? encodeURIComponent(resolved) : resolved; }); if (containsSecretSentinel(swapped)) throw new SecretEgressSubstitutionError("unresolved-sentinel"); return { value: swapped, substituted }; } function swapRequestHeaders(params) { const output = {}; let substituted = false; const swap = (value) => { const swapped = swapRequestText({ value, urlMode: false, host: params.host, registered: params.registered }); substituted ||= swapped.substituted; return swapped.value; }; for (const [name, rawValue] of Object.entries(params.headers)) { const lowerName = name.toLowerCase(); if (lowerName === "proxy-authorization" || lowerName === "proxy-connection") continue; if (Array.isArray(rawValue)) output[name] = rawValue.map(swap); else if (rawValue !== void 0) output[name] = swap(rawValue); } return { headers: output, substituted }; } /** Starts one authenticated, loopback-only substitution proxy. */ async function startSecretEgressProxyServer(params) { const certificates = await createSecretEgressCertificates(params.caDir); const { caPem } = certificates; const trustBundlePath = path.join(params.caDir, "trust-bundle.pem"); fs.writeFileSync(trustBundlePath, `${rootCertificates.join("\n")}\n${caPem}`, { mode: 420 }); const upstreamSecureContext = createSecureContext({ ca: [...rootCertificates, caPem] }); const bypassHosts = new Set((params.bypassHosts ?? []).map(normalizeExactAllowedHost)); const allowedHosts = params.allowedHosts === void 0 ? void 0 : new Set(params.allowedHosts.map(normalizeExactAllowedHost)); const registrations = /* @__PURE__ */ new Set(); const acquireBody = createSecretEgressBodyBudget(); const sockets = /* @__PURE__ */ new Set(); let stopped = false; let stopPromise; const ownResource = (registered, resource) => { if (!registered.resources.has(resource)) { registered.resources.add(resource); resource.once("close", () => registered.resources.delete(resource)); const wasFlowing = resource instanceof Readable && resource.readableFlowing === true; resource.prependListener("data", () => { if (!registered.isActive()) revokeRegistration(registered); }); if (resource instanceof Readable && !wasFlowing) resource.pause(); resource.on("error", () => resource.destroy()); } if (!registered.isActive()) resource.destroy(); return resource; }; const revokeRegistration = (registered) => { registrations.delete(registered); registered.sentinelBindings.clear(); registered.upstreamTlsAgent.destroy(); for (const resource of registered.resources) resource.destroy(); registered.resources.clear(); for (const server of registered.tlsServers.values()) server.close(); registered.tlsServers.clear(); }; const audit = (event) => params.onAudit(event); const hostAllowed = (host, registered) => { if (allowedHosts === void 0 || allowedHosts.has(host) || bypassHosts.has(host)) return true; for (const binding of registered.sentinelBindings.values()) if (binding.allowedHosts.has(host)) return true; return false; }; const hostNotAllowedBody = (host) => `Host "${host}" is not in the secret egress proxy traffic allowlist. Add it to secrets.egressProxy.allowedHosts or bind a store secret to it with: openclaw secrets store set <NAME> --allow-host ${host}, then restart the Gateway.\n`; const authorize = (headers) => { const rawHeader = headers["proxy-authorization"]; if (rawHeader === void 0) return "missing-proxy-auth"; const password = parseBasicProxyPassword(rawHeader); if (!password) return "invalid-proxy-auth"; const candidate = parseProxyToken(password); if (!candidate) return "invalid-proxy-auth"; for (const registered of registrations.values()) if (registered.isActive() && timingSafeEqual(candidate, registered.token)) return registered; return "invalid-proxy-auth"; }; const parseRequestTarget = (request, response, base) => { try { const target = new URL(request.url ?? "/", base); return { target, host: normalizeExactAllowedHost(target.hostname) }; } catch { audit({ kind: "refused", host: "unknown", substituted: false, reason: "upstream-error" }); sendHttpRefusal(response, 400); request.resume(); return; } }; const forwardRequest = (forward) => { ownResource(forward.registered, forward.request); ownResource(forward.registered, forward.response); if (forward.upgrade) ownResource(forward.registered, forward.upgrade.stream); if (!forward.registered.isActive()) return; if (forward.upgrade && (forward.request.method !== "GET" || forward.request.headers.upgrade?.toLowerCase() !== "websocket")) { sendHttpRefusal(forward.response, 400); return; } const { host } = forward; if (forward.target.protocol !== "https:") { audit({ kind: "refused", host, substituted: false, reason: "non-https-request" }); sendHttpRefusal(forward.response); forward.request.resume(); return; } if (!hostAllowed(host, forward.registered)) { audit({ kind: "refused", host, substituted: false, reason: "host-not-allowed" }); sendHttpRefusal(forward.response, 403, hostNotAllowedBody(host)); forward.request.resume(); return; } forwardSecretEgressRequest({ request: forward.request, response: forward.response, upgrade: forward.upgrade, host, acquireBody, prepareRequest: () => { if (!hostAllowed(host, forward.registered)) { const error = new SecretEgressSubstitutionError("host-not-allowed"); error.message = hostNotAllowedBody(host).trimEnd(); throw error; } const swappedUrl = swapRequestText({ value: forward.target.toString(), urlMode: true, host, registered: forward.registered }); const target = new URL(swappedUrl.value); const swappedHeaders = swapRequestHeaders({ headers: forward.request.headers, host, registered: forward.registered }); swappedHeaders.headers.host = target.host; return { target, headers: swappedHeaders.headers, substituted: swappedUrl.substituted || swappedHeaders.substituted }; }, upstreamTlsAgent: forward.registered.upstreamTlsAgent, isActive: forward.registered.isActive, ownResource: (resource) => ownResource(forward.registered, resource), releaseResponse: () => { forward.registered.resources.delete(forward.response); }, resolveSentinel: (sentinel) => resolveRegisteredSentinel({ sentinel, host, registered: forward.registered }), audit }); }; const tlsServerFor = (target, registered) => { const key = `${target.hostname}:${target.port}`; let context = registered.tlsServers.get(key); if (!context) { context = certificates.createContext({ hostname: target.hostname, isActive: registered.isActive, createServer: (leaf) => { const handleRequest = (request, response, upgrade) => { const parsed = parseRequestTarget(request, response, `https://${target.hostname}${target.port === 443 ? "" : `:${target.port}`}`); if (parsed) forwardRequest({ request, response, ...parsed, registered, upgrade }); }; const httpServer = createServer$1(handleRequest).on("upgrade", (request, _socket, head) => handleUpgradeRequest(handleRequest, request, head)); const tlsServer = createServer$3(leaf).on("secureConnection", (socket) => { ownResource(registered, socket); httpServer.emit("connection", socket); }); tlsServer.on("tlsClientError", (_error, socket) => socket.destroy()); return { acceptConnection: (socket) => tlsServer.emit("connection", socket), close: () => { tlsServer.close(); httpServer.close(); }, setSecureContext: (options) => tlsServer.setSecureContext(options) }; } }); registered.tlsServers.set(key, context); } return context.get(); }; const handleProxyRequest = (request, response, upgrade) => { const parsed = parseRequestTarget(request, response); if (!parsed) return; const { host } = parsed; const authorization = authorize(request.headers); if (typeof authorization === "string") { audit({ kind: "refused", host, substituted: false, reason: authorization }); response.writeHead(407, { "Proxy-Authenticate": `Basic realm="${PROXY_AUTH_REALM}"`, Connection: "close", "Content-Length": Buffer.byteLength(REFUSAL_BODY), "Content-Type": "text/plain; charset=utf-8" }); response.end(REFUSAL_BODY); request.resume(); return; } forwardRequest({ request, response, ...parsed, registered: authorization, upgrade }); }; const proxy = createServer$1(handleProxyRequest).on("upgrade", (request, _socket, head) => handleUpgradeRequest(handleProxyRequest, request, head)); proxy.on("connection", (socket) => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); socket.on("error", () => { socket.destroy(); }); }); proxy.on("connect", (request, clientSocket, head) => { (async () => { let target; try { target = parseConnectTarget(request.url); } catch { audit({ kind: "refused", host: "unknown", substituted: false, reason: "upstream-error" }); clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); return; } const authorization = authorize(request.headers); if (typeof authorization === "string") { audit({ kind: "refused", host: target.hostname, substituted: false, reason: authorization }); sendProxyAuthRequired(clientSocket); return; } ownResource(authorization, clientSocket); if (bypassHosts.has(target.hostname)) { const upstream = ownResource(authorization, net.connect(target.port, target.hostname, () => { if (!authorization.isActive() || clientSocket.destroyed) { upstream.destroy(); return; } clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head.length > 0) upstream.write(head); clientSocket.pipe(upstream).pipe(clientSocket); audit({ kind: "forwarded", host: target.hostname, substituted: false, reason: "bypass" }); })); clientSocket.once("close", () => upstream.destroy()); upstream.once("close", () => clientSocket.destroy()); upstream.once("error", () => clientSocket.destroy()); return; } if (!hostAllowed(target.hostname, authorization)) { const body = hostNotAllowedBody(target.hostname); audit({ kind: "refused", host: target.hostname, substituted: false, reason: "host-not-allowed" }); clientSocket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}`); return; } try { const tlsServer = await tlsServerFor(target, authorization); if (!tlsServer || !authorization.isActive() || clientSocket.destroyed) { clientSocket.destroy(); return; } clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head.length > 0) clientSocket.unshift(head); tlsServer.acceptConnection(clientSocket); } catch (error) { if (!authorization.isActive() || clientSocket.destroyed) return; audit({ kind: "refused", host: target.hostname, substituted: false, reason: "certificate-error" }); const body = `${error instanceof SecretEgressCertificateError ? error.message : "Secret egress TLS certificate unavailable. Check Gateway logs, then retry."}\n`; clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); } })(); }); await new Promise((resolve, reject) => { proxy.once("error", reject); proxy.listen(0, "127.0.0.1", () => { proxy.off("error", reject); resolve(); }); }); const address = proxy.address(); if (!address || typeof address === "string") throw new Error("Secret egress proxy failed to bind loopback"); const proxyOrigin = `http://127.0.0.1:${address.port}`; return { caCertPath: certificates.caCertPath, proxyOrigin, getCertificateStatus: certificates.getStatus, registerProcess: (bindings = [], isActive = () => true) => { if (stopped) throw new Error("Secret egress proxy has stopped"); const registered = { sentinelBindings: new Map(bindings.map((binding) => [binding.sentinel, { allowedHosts: new Set(binding.allowedHosts.map(normalizeExactAllowedHost)), name: binding.name }])), token: randomBytes(32), isActive: () => !stopped && registrations.has(registered) && isActive(), resolveSentinel: params.resolveSentinel ?? resolveSecretSentinel, resources: /* @__PURE__ */ new Set(), tlsServers: /* @__PURE__ */ new Map(), upstreamTlsAgent: new Agent$1({ secureContext: upstreamSecureContext, keepAlive: true, maxSockets: 64, maxTotalSockets: 64, maxFreeSockets: 4, timeout: 3e4 }) }; registrations.add(registered); const token = registered.token.toString("base64url"); const proxyUrl = `http://${PROXY_AUTH_USERNAME}:${token}@127.0.0.1:${address.port}`; return { env: { HTTPS_PROXY: proxyUrl, HTTP_PROXY: proxyUrl, NODE_USE_ENV_PROXY: "1", NODE_EXTRA_CA_CERTS: trustBundlePath, SSL_CERT_FILE: trustBundlePath, CURL_CA_BUNDLE: trustBundlePath, REQUESTS_CA_BUNDLE: trustBundlePath, GIT_SSL_CAINFO: trustBundlePath }, revoke: () => revokeRegistration(registered) }; }, stop: () => { if (stopPromise) return stopPromise; stopped = true; for (const registered of registrations.values()) revokeRegistration(registered); for (const socket of sockets) socket.destroy(); sockets.clear(); stopPromise = Promise.all([new Promise((resolve) => { proxy.close(() => resolve()); }), certificates.waitForPreparations()]).then(() => {}); return stopPromise; } }; } //#endregion export { startSecretEgressProxyServer as t };