openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
381 lines (380 loc) • 13.7 kB
JavaScript
import { j as resolveTimerTimeoutMs } from "./number-coercion-CJQ8TR--.js";
import { n as getActiveManagedProxyTlsOptions, r as getActiveManagedProxyUrl } from "./active-proxy-state-DJLhrP_Z.js";
import * as net$1 from "node:net";
import * as tls$1 from "node:tls";
import http2 from "node:http2";
//#region src/infra/net/http-connect-tunnel.ts
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
const MIN_CONNECT_TIMEOUT_MS = 1;
function redactProxyUrl(proxyUrl) {
try {
return proxyUrl.origin;
} catch {
return "<invalid proxy URL>";
}
}
function resolveProxyHost(proxy) {
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
}
function resolveProxyPort(proxy) {
if (proxy.port) return Number(proxy.port);
return proxy.protocol === "https:" ? 443 : 80;
}
function resolveProxyAuthorization(proxy) {
if (!proxy.username && !proxy.password) return;
const username = decodeURIComponent(proxy.username);
const password = decodeURIComponent(proxy.password);
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function formatTunnelFailure(proxyUrl, err) {
return new Error(`Proxy CONNECT failed via ${redactProxyUrl(proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
}
function writeConnectRequest(socket, proxy, target) {
const headers = [
`CONNECT ${target} HTTP/1.1`,
`Host: ${target}`,
"Proxy-Connection: Keep-Alive"
];
const authorization = resolveProxyAuthorization(proxy);
if (authorization) headers.push(`Proxy-Authorization: ${authorization}`);
socket.write([
...headers,
"",
""
].join("\r\n"));
}
function assertConnectHeaderBytesWithinLimit(size) {
if (size > MAX_CONNECT_RESPONSE_HEADER_BYTES) throw new Error(`Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`);
}
function readProxyConnectResponse(responseBuffer, chunk) {
const nextBuffer = Buffer.concat([responseBuffer, chunk]);
const headerEnd = nextBuffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
assertConnectHeaderBytesWithinLimit(nextBuffer.length);
return {
kind: "incomplete",
responseBuffer: nextBuffer
};
}
const bodyOffset = headerEnd + 4;
assertConnectHeaderBytesWithinLimit(bodyOffset);
return {
kind: "complete",
responseBuffer: nextBuffer,
statusLine: nextBuffer.subarray(0, bodyOffset).toString("latin1").split("\r\n", 1)[0] ?? "",
tunneledBytes: nextBuffer.length > bodyOffset ? nextBuffer.subarray(bodyOffset) : void 0
};
}
function isSuccessfulConnectStatusLine(statusLine) {
return /^HTTP\/1\.[01] 2\d\d\b/.test(statusLine);
}
function connectToProxy(proxy, proxyTls) {
const proxyHost = resolveProxyHost(proxy);
const proxyServername = net$1.isIP(proxyHost) === 0 ? proxyHost : void 0;
const connectOptions = {
host: proxyHost,
port: resolveProxyPort(proxy)
};
if (proxy.protocol === "https:") return tls$1.connect({
...connectOptions,
...proxyServername ? { servername: proxyServername } : {},
ALPNProtocols: ["http/1.1"],
...proxyTls?.ca ? { ca: proxyTls.ca } : {}
});
return net$1.connect(connectOptions);
}
var HttpConnectTunnelAttempt = class {
constructor(params, proxy, resolve, reject) {
this.params = params;
this.proxy = proxy;
this.resolve = resolve;
this.reject = reject;
this.settled = false;
this.responseBuffer = Buffer.alloc(0);
this.fail = (err) => {
if (this.settled) return;
this.settled = true;
this.clearTimer();
this.cleanupProxyListeners();
this.cleanupTargetTlsListeners();
this.targetTlsSocket?.destroy();
this.proxySocket?.destroy();
this.reject(formatTunnelFailure(this.params.proxyUrl, err));
};
this.onProxyConnected = () => {
const socket = this.proxySocket;
if (!socket) {
this.fail(/* @__PURE__ */ new Error("Proxy socket missing after connect"));
return;
}
const target = `${this.params.targetHost}:${this.params.targetPort}`;
try {
writeConnectRequest(socket, this.proxy, target);
} catch (err) {
this.fail(err);
}
};
this.onProxyData = (chunk) => {
let result;
try {
result = readProxyConnectResponse(this.responseBuffer, chunk);
} catch (err) {
this.fail(err);
return;
}
this.responseBuffer = result.responseBuffer;
if (result.kind === "incomplete") return;
const socket = this.proxySocket;
if (!socket) {
this.fail(/* @__PURE__ */ new Error("Proxy socket missing after CONNECT response"));
return;
}
if (result.tunneledBytes) socket.unshift(result.tunneledBytes);
if (!isSuccessfulConnectStatusLine(result.statusLine)) {
this.fail(new Error(result.statusLine || "Proxy returned an invalid CONNECT response"));
return;
}
this.cleanupProxyListeners();
this.startTargetTls(socket);
};
this.onTargetSecureConnect = () => {
const socket = this.targetTlsSocket;
if (!socket) {
this.fail(/* @__PURE__ */ new Error("APNs TLS socket missing after secureConnect"));
return;
}
if (socket.alpnProtocol !== "h2") {
const negotiated = socket.alpnProtocol || "no ALPN protocol";
this.fail(/* @__PURE__ */ new Error(`APNs TLS tunnel negotiated ${negotiated} instead of h2`));
return;
}
this.succeed(socket);
};
this.onTargetTlsClosedBeforeSecureConnect = () => {
this.fail(/* @__PURE__ */ new Error("APNs TLS tunnel closed before secureConnect"));
};
this.onProxyClosedBeforeConnect = () => {
this.fail(/* @__PURE__ */ new Error("Proxy closed before CONNECT response"));
};
}
start() {
try {
this.startTimeout();
this.proxySocket = connectToProxy(this.proxy, this.params.proxyTls);
this.proxySocket.once(this.proxy.protocol === "https:" ? "secureConnect" : "connect", this.onProxyConnected);
this.proxySocket.on("data", this.onProxyData);
this.proxySocket.once("end", this.onProxyClosedBeforeConnect);
this.proxySocket.once("error", this.fail);
this.proxySocket.once("close", this.onProxyClosedBeforeConnect);
} catch (err) {
this.fail(err);
}
}
startTimeout() {
const timeoutMs = this.params.timeoutMs === void 0 || this.params.timeoutMs <= 0 ? void 0 : resolveTimerTimeoutMs(this.params.timeoutMs, MIN_CONNECT_TIMEOUT_MS);
if (timeoutMs !== void 0) this.timeout = setTimeout(() => {
this.fail(/* @__PURE__ */ new Error(`Proxy CONNECT timed out after ${timeoutMs}ms`));
}, timeoutMs);
}
clearTimer() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = void 0;
}
}
cleanupProxyListeners() {
const socket = this.proxySocket;
if (!socket) return;
socket.off("data", this.onProxyData);
socket.off("end", this.onProxyClosedBeforeConnect);
socket.off("error", this.fail);
socket.off("close", this.onProxyClosedBeforeConnect);
socket.off("connect", this.onProxyConnected);
socket.off("secureConnect", this.onProxyConnected);
}
cleanupTargetTlsListeners() {
const socket = this.targetTlsSocket;
if (!socket) return;
socket.off("secureConnect", this.onTargetSecureConnect);
socket.off("error", this.fail);
socket.off("close", this.onTargetTlsClosedBeforeSecureConnect);
}
succeed(socket) {
if (this.settled) {
socket.destroy();
return;
}
this.settled = true;
this.clearTimer();
this.cleanupProxyListeners();
this.cleanupTargetTlsListeners();
this.resolve(socket);
}
startTargetTls(socket) {
try {
this.targetTlsSocket = tls$1.connect({
socket,
servername: this.params.targetHost,
ALPNProtocols: ["h2"]
});
this.targetTlsSocket.once("secureConnect", this.onTargetSecureConnect);
this.targetTlsSocket.once("error", this.fail);
this.targetTlsSocket.once("close", this.onTargetTlsClosedBeforeSecureConnect);
} catch (err) {
this.fail(err);
}
}
};
/** Opens a TLS-over-CONNECT tunnel and verifies the target negotiated HTTP/2. */
async function openHttpConnectTunnel(params) {
const proxy = new URL(params.proxyUrl.href);
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`);
return await new Promise((resolve, reject) => {
new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start();
});
}
//#endregion
//#region src/infra/push-apns-http2.ts
const APNS_DEFAULT_PORT = "443";
const APNS_AUTHORITIES = new Set(["https://api.push.apple.com", "https://api.sandbox.push.apple.com"]);
const APNS_HTTP2_CANCEL_CODE = http2.constants.NGHTTP2_CANCEL;
const APNS_RESPONSE_BODY_MAX_BYTES = 8192;
const APNS_HTTP2_MIN_TIMEOUT_MS = 1e3;
function assertApnsAuthority(authority) {
let parsed;
try {
parsed = new URL(authority);
} catch {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error(`Unsupported APNs authority: ${authority}`);
const port = parsed.port && parsed.port !== APNS_DEFAULT_PORT ? `:${parsed.port}` : "";
const normalized = `${parsed.protocol}//${parsed.hostname}${port}`;
if (!APNS_AUTHORITIES.has(normalized)) throw new Error(`Unsupported APNs authority: ${authority}`);
return normalized;
}
async function openProxiedApnsHttp2Session(params) {
const apnsHost = new URL(params.authority).hostname;
const tlsSocket = await openHttpConnectTunnel({
proxyUrl: params.proxyUrl,
...params.proxyTls ? { proxyTls: params.proxyTls } : {},
targetHost: apnsHost,
targetPort: 443,
timeoutMs: params.timeoutMs
});
return http2.connect(params.authority, { createConnection: () => tlsSocket });
}
/** Connects to APNs directly, or through the active managed proxy when present. */
async function connectApnsHttp2Session(params) {
const authority = assertApnsAuthority(params.authority);
const timeoutMs = resolveApnsHttp2TimeoutMs(params.timeoutMs);
const proxyUrl = getActiveManagedProxyUrl();
if (!proxyUrl) return http2.connect(authority);
return await openProxiedApnsHttp2Session({
authority,
proxyUrl,
proxyTls: getActiveManagedProxyTlsOptions(),
timeoutMs
});
}
function resolveApnsHttp2TimeoutMs(timeoutMs) {
return resolveTimerTimeoutMs(timeoutMs, APNS_HTTP2_MIN_TIMEOUT_MS, APNS_HTTP2_MIN_TIMEOUT_MS);
}
function createApnsResponseBodyCapture() {
return {
text: "",
bytes: 0,
truncated: false
};
}
function appendApnsResponseBodyCapture(capture, chunk, maxBytes = APNS_RESPONSE_BODY_MAX_BYTES) {
const buffer = Buffer.from(String(chunk));
capture.bytes += buffer.byteLength;
const remaining = maxBytes - Buffer.byteLength(capture.text);
if (remaining <= 0) {
capture.truncated = capture.truncated || buffer.byteLength > 0;
return;
}
const slice = buffer.byteLength > remaining ? buffer.subarray(0, remaining) : buffer;
capture.text += slice.toString("utf8");
if (slice.byteLength < buffer.byteLength) capture.truncated = true;
}
/** Sends an intentionally invalid APNs push through a proxy to prove HTTP/2 reachability. */
async function probeApnsHttp2ReachabilityViaProxy(params) {
const authority = assertApnsAuthority(params.authority);
const timeoutMs = resolveApnsHttp2TimeoutMs(params.timeoutMs);
const session = await openProxiedApnsHttp2Session({
authority,
proxyUrl: new URL(params.proxyUrl),
...params.proxyTls ? { proxyTls: params.proxyTls } : {},
timeoutMs
});
try {
return await new Promise((resolve, reject) => {
let settled = false;
const body = createApnsResponseBodyCapture();
let status;
let responseHeaders = {};
const timeout = setTimeout(() => {
fail(/* @__PURE__ */ new Error(`APNs reachability probe timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeout.unref?.();
const cleanup = () => {
clearTimeout(timeout);
session.off("error", fail);
};
const fail = (err) => {
if (settled) return;
settled = true;
cleanup();
session.destroy(err instanceof Error ? err : new Error(String(err)));
reject(toLintErrorObject(err, "Non-Error rejection"));
};
const request = session.request({
":method": "POST",
":path": `/3/device/${"0".repeat(64)}`,
authorization: "bearer intentionally.invalid.openclaw.proxy.validation",
"apns-topic": "ai.openclaw.ios",
"apns-push-type": "alert",
"apns-priority": "10"
});
session.once("error", fail);
request.setEncoding("utf8");
request.on("response", (headers) => {
const rawStatus = headers[":status"];
status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus);
responseHeaders = Object.fromEntries(Object.entries(headers).filter(([k]) => !k.startsWith(":")).map(([k, v]) => [k, String(v)]));
});
request.on("data", (chunk) => {
appendApnsResponseBodyCapture(body, chunk);
});
request.once("error", fail);
request.once("end", () => {
if (settled) return;
settled = true;
cleanup();
if (status === void 0 || !Number.isFinite(status)) {
reject(/* @__PURE__ */ new Error("APNs reachability probe ended without an HTTP/2 status"));
return;
}
resolve({
status,
body: body.text,
responseHeaders
});
});
request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } }));
});
} finally {
if (!session.closed && !session.destroyed) session.close();
}
}
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
const error = new Error(fallbackMessage, { cause: value });
if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value);
return error;
}
//#endregion
export { probeApnsHttp2ReachabilityViaProxy as a, createApnsResponseBodyCapture as i, appendApnsResponseBodyCapture as n, connectApnsHttp2Session as r, APNS_HTTP2_CANCEL_CODE as t };