mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
refactor(proxy): use native CONNECT tunnel
This commit is contained in:
@@ -1,385 +0,0 @@
|
||||
// HTTP CONNECT tunnel tests cover HTTP/HTTPS proxy handshakes, proxy auth,
|
||||
// timeout/error cleanup, and tunneled-byte preservation.
|
||||
import { EventEmitter } from "node:events";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
public readonly writes: string[] = [];
|
||||
public readonly unshifted: Buffer[] = [];
|
||||
public destroyed = false;
|
||||
public writable = true;
|
||||
public readonly alpnProtocol: string | false;
|
||||
public readonly emitSecureConnectOnConnect: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly response?: string,
|
||||
options: { alpnProtocol?: string | false; emitSecureConnectOnConnect?: boolean } = {},
|
||||
) {
|
||||
super();
|
||||
this.alpnProtocol = options.alpnProtocol ?? "h2";
|
||||
this.emitSecureConnectOnConnect = options.emitSecureConnectOnConnect ?? true;
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
const response = this.response;
|
||||
if (response !== undefined) {
|
||||
queueMicrotask(() => this.emit("data", Buffer.from(response, "latin1")));
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.writable = false;
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
unshift(data: Buffer): void {
|
||||
this.unshifted.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
netConnectSpy,
|
||||
tlsConnectSpy,
|
||||
setNextNetSocket,
|
||||
setNextProxyTlsSocket,
|
||||
setNextTargetTlsSocket,
|
||||
} = vi.hoisted(() => {
|
||||
let nextNetSocket: FakeSocket | undefined;
|
||||
let nextProxyTlsSocket: FakeSocket | undefined;
|
||||
let nextTargetTlsSocket: FakeSocket | undefined;
|
||||
|
||||
return {
|
||||
setNextNetSocket: (socket: FakeSocket) => {
|
||||
nextNetSocket = socket;
|
||||
},
|
||||
setNextProxyTlsSocket: (socket: FakeSocket) => {
|
||||
nextProxyTlsSocket = socket;
|
||||
},
|
||||
setNextTargetTlsSocket: (socket: FakeSocket) => {
|
||||
nextTargetTlsSocket = socket;
|
||||
},
|
||||
netConnectSpy: vi.fn(() => {
|
||||
if (!nextNetSocket) {
|
||||
throw new Error("nextNetSocket not set");
|
||||
}
|
||||
const socket = nextNetSocket;
|
||||
queueMicrotask(() => socket.emit("connect"));
|
||||
return socket;
|
||||
}),
|
||||
tlsConnectSpy: vi.fn((options: { socket?: FakeSocket }) => {
|
||||
if (options.socket) {
|
||||
if (!nextTargetTlsSocket) {
|
||||
throw new Error("nextTargetTlsSocket not set");
|
||||
}
|
||||
const socket = nextTargetTlsSocket;
|
||||
if (socket.emitSecureConnectOnConnect) {
|
||||
queueMicrotask(() => socket.emit("secureConnect"));
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
if (!nextProxyTlsSocket) {
|
||||
throw new Error("nextProxyTlsSocket not set");
|
||||
}
|
||||
const socket = nextProxyTlsSocket;
|
||||
queueMicrotask(() => socket.emit("secureConnect"));
|
||||
return socket;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:net", () => ({
|
||||
connect: netConnectSpy,
|
||||
isIP: (host: string) => {
|
||||
if (host === "127.0.0.1") {
|
||||
return 4;
|
||||
}
|
||||
if (host === "::1") {
|
||||
return 6;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:tls", () => ({
|
||||
connect: tlsConnectSpy,
|
||||
}));
|
||||
|
||||
function requireFirstTlsConnectOptions(): unknown {
|
||||
const [call] = tlsConnectSpy.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected TLS connect call");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
describe("openHttpConnectTunnel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
netConnectSpy.mockClear();
|
||||
tlsConnectSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens an HTTP CONNECT tunnel through the configured proxy", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket();
|
||||
setNextNetSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
const result = await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toBe(targetTlsSocket);
|
||||
expect(netConnectSpy).toHaveBeenCalledWith({ host: "proxy.example", port: 8080 });
|
||||
expect(proxySocket.writes[0]).toBe(
|
||||
[
|
||||
"CONNECT api.push.apple.com:443 HTTP/1.1",
|
||||
"Host: api.push.apple.com:443",
|
||||
"Proxy-Connection: Keep-Alive",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
expect(tlsConnectSpy).toHaveBeenLastCalledWith({
|
||||
socket: proxySocket,
|
||||
servername: "api.push.apple.com",
|
||||
ALPNProtocols: ["h2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports HTTPS proxy URLs", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket();
|
||||
setNextProxyTlsSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("https://proxy.example:8443"),
|
||||
proxyTls: { ca: "proxy-ca" },
|
||||
targetHost: "api.sandbox.push.apple.com",
|
||||
targetPort: 443,
|
||||
});
|
||||
|
||||
expect(requireFirstTlsConnectOptions()).toEqual({
|
||||
host: "proxy.example",
|
||||
port: 8443,
|
||||
servername: "proxy.example",
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
ca: "proxy-ca",
|
||||
});
|
||||
expect(tlsConnectSpy).toHaveBeenLastCalledWith({
|
||||
socket: proxySocket,
|
||||
servername: "api.sandbox.push.apple.com",
|
||||
ALPNProtocols: ["h2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits SNI for HTTPS proxy IP literals", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket();
|
||||
setNextProxyTlsSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("https://127.0.0.1:8443"),
|
||||
proxyTls: { ca: "proxy-ca" },
|
||||
targetHost: "api.sandbox.push.apple.com",
|
||||
targetPort: 443,
|
||||
});
|
||||
|
||||
expect(requireFirstTlsConnectOptions()).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 8443,
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
ca: "proxy-ca",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends basic proxy authorization and redacts credentials when CONNECT fails", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await expect(
|
||||
openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://user:secret@proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: HTTP/1.1 407 Proxy Authentication Required",
|
||||
);
|
||||
expect(proxySocket.writes[0]).toContain(
|
||||
`Proxy-Authorization: Basic ${Buffer.from("user:secret").toString("base64")}`,
|
||||
);
|
||||
expect(proxySocket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("redacts proxy URL query and fragment values when CONNECT fails", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://user:secret@proxy.example:8080/?token=hidden#fragment"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
if (!(caught instanceof Error)) {
|
||||
throw new Error("expected CONNECT failure");
|
||||
}
|
||||
expect(caught.message).toBe(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: HTTP/1.1 407 Proxy Authentication Required",
|
||||
);
|
||||
expect(caught.message).not.toContain("secret");
|
||||
expect(caught.message).not.toContain("hidden");
|
||||
expect(caught.message).not.toContain("fragment");
|
||||
});
|
||||
|
||||
it("rejects malformed proxy credentials through the normal cleanup path", async () => {
|
||||
const proxySocket = new FakeSocket();
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await expect(
|
||||
openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://%E0%A4%A@proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
}),
|
||||
).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: URI malformed");
|
||||
expect(proxySocket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("caps unterminated CONNECT response headers", async () => {
|
||||
const proxySocket = new FakeSocket(`HTTP/1.1 200 ${"a".repeat(17 * 1024)}`);
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await expect(
|
||||
openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT response headers exceeded 16384 bytes",
|
||||
);
|
||||
expect(proxySocket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for APNs TLS secureConnect before resolving", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket(undefined, { emitSecureConnectOnConnect: false });
|
||||
setNextNetSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
let resolved = false;
|
||||
const tunnel = openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
}).then((socket) => {
|
||||
resolved = true;
|
||||
return socket;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
targetTlsSocket.emit("secureConnect");
|
||||
|
||||
await expect(tunnel).resolves.toBe(targetTlsSocket);
|
||||
});
|
||||
|
||||
it("rejects APNs TLS tunnels that do not negotiate h2", async () => {
|
||||
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
const targetTlsSocket = new FakeSocket(undefined, { alpnProtocol: "http/1.1" });
|
||||
setNextNetSocket(proxySocket);
|
||||
setNextTargetTlsSocket(targetTlsSocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
await expect(
|
||||
openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: APNs TLS tunnel negotiated http/1.1 instead of h2",
|
||||
);
|
||||
expect(targetTlsSocket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects and destroys the proxy socket when CONNECT times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const proxySocket = new FakeSocket();
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
const tunnel = openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
timeoutMs: 1,
|
||||
});
|
||||
void tunnel.catch(() => undefined);
|
||||
const rejected = expect(tunnel).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1ms",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await rejected;
|
||||
expect(proxySocket.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("caps oversized CONNECT timeouts before arming the watchdog", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const proxySocket = new FakeSocket();
|
||||
setNextNetSocket(proxySocket);
|
||||
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
|
||||
|
||||
const tunnel = openHttpConnectTunnel({
|
||||
proxyUrl: new URL("http://proxy.example:8080"),
|
||||
targetHost: "api.push.apple.com",
|
||||
targetPort: 443,
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
void tunnel.catch(() => undefined);
|
||||
const rejected = expect(tunnel).rejects.toThrow(
|
||||
`Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after ${MAX_TIMER_TIMEOUT_MS}ms`,
|
||||
);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(proxySocket.destroyed).toBe(false);
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
await rejected;
|
||||
expect(proxySocket.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
// HTTP CONNECT tunnel support opens TLS target sockets through HTTP(S) forward
|
||||
// proxies for APNs and similar clients.
|
||||
import * as net from "node:net";
|
||||
import * as tls from "node:tls";
|
||||
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import type { ManagedProxyTlsOptions } from "./proxy/proxy-tls.js";
|
||||
|
||||
/** Parameters for opening an APNs HTTP/2 tunnel through an HTTP(S) forward proxy. */
|
||||
type HttpConnectTunnelParams = {
|
||||
proxyUrl: URL;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
||||
const MIN_CONNECT_TIMEOUT_MS = 1;
|
||||
|
||||
type ProxySocket = net.Socket | tls.TLSSocket;
|
||||
type ConnectResponseBuffer = Buffer;
|
||||
|
||||
type ProxyConnectReadResult =
|
||||
| {
|
||||
kind: "incomplete";
|
||||
responseBuffer: ConnectResponseBuffer;
|
||||
}
|
||||
| {
|
||||
kind: "complete";
|
||||
responseBuffer: ConnectResponseBuffer;
|
||||
statusLine: string;
|
||||
tunneledBytes: ConnectResponseBuffer | undefined;
|
||||
};
|
||||
|
||||
function redactProxyUrl(proxyUrl: URL): string {
|
||||
try {
|
||||
return proxyUrl.origin;
|
||||
} catch {
|
||||
return "<invalid proxy URL>";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProxyHost(proxy: URL): string {
|
||||
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
function resolveProxyPort(proxy: URL): number {
|
||||
if (proxy.port) {
|
||||
return Number(proxy.port);
|
||||
}
|
||||
return proxy.protocol === "https:" ? 443 : 80;
|
||||
}
|
||||
|
||||
function resolveProxyAuthorization(proxy: URL): string | undefined {
|
||||
if (!proxy.username && !proxy.password) {
|
||||
return undefined;
|
||||
}
|
||||
const username = decodeURIComponent(proxy.username);
|
||||
const password = decodeURIComponent(proxy.password);
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
function formatTunnelFailure(proxyUrl: URL, err: unknown): Error {
|
||||
return new Error(
|
||||
`Proxy CONNECT failed via ${redactProxyUrl(proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
|
||||
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: number): void {
|
||||
if (size > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
||||
throw new Error(
|
||||
`Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readProxyConnectResponse(
|
||||
responseBuffer: ConnectResponseBuffer,
|
||||
chunk: ConnectResponseBuffer,
|
||||
): ProxyConnectReadResult {
|
||||
// CONNECT response data can include the first bytes of the target TLS stream;
|
||||
// preserve them with unshift once the tunnel is established.
|
||||
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);
|
||||
|
||||
const responseHeader = nextBuffer.subarray(0, bodyOffset).toString("latin1");
|
||||
const statusLine = responseHeader.split("\r\n", 1)[0] ?? "";
|
||||
// CONNECT can coalesce response headers and first tunneled bytes. Preserve
|
||||
// those bytes so the target TLS handshake sees the stream from byte zero.
|
||||
const tunneledBytes =
|
||||
nextBuffer.length > bodyOffset ? nextBuffer.subarray(bodyOffset) : undefined;
|
||||
return {
|
||||
kind: "complete",
|
||||
responseBuffer: nextBuffer,
|
||||
statusLine,
|
||||
tunneledBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function isSuccessfulConnectStatusLine(statusLine: string): boolean {
|
||||
return /^HTTP\/1\.[01] 2\d\d\b/.test(statusLine);
|
||||
}
|
||||
|
||||
function connectToProxy(proxy: URL, proxyTls: ManagedProxyTlsOptions | undefined): ProxySocket {
|
||||
const proxyHost = resolveProxyHost(proxy);
|
||||
// TLS SNI cannot be an IP literal; omit it for IP-addressed HTTPS proxies.
|
||||
const proxyServername = net.isIP(proxyHost) === 0 ? proxyHost : undefined;
|
||||
const connectOptions = {
|
||||
host: proxyHost,
|
||||
port: resolveProxyPort(proxy),
|
||||
};
|
||||
if (proxy.protocol === "https:") {
|
||||
return tls.connect({
|
||||
...connectOptions,
|
||||
...(proxyServername ? { servername: proxyServername } : {}),
|
||||
ALPNProtocols: ["http/1.1"],
|
||||
...(proxyTls?.ca ? { ca: proxyTls.ca } : {}),
|
||||
});
|
||||
}
|
||||
return net.connect(connectOptions);
|
||||
}
|
||||
|
||||
class HttpConnectTunnelAttempt {
|
||||
private proxySocket: ProxySocket | undefined;
|
||||
private targetTlsSocket: tls.TLSSocket | undefined;
|
||||
private timeout: NodeJS.Timeout | undefined;
|
||||
private settled = false;
|
||||
private responseBuffer: ConnectResponseBuffer = Buffer.alloc(0);
|
||||
|
||||
constructor(
|
||||
private readonly params: HttpConnectTunnelParams,
|
||||
private readonly proxy: URL,
|
||||
private readonly resolve: (socket: tls.TLSSocket) => void,
|
||||
private readonly reject: (reason?: unknown) => void,
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private startTimeout(): void {
|
||||
const timeoutMs =
|
||||
this.params.timeoutMs === undefined || this.params.timeoutMs <= 0
|
||||
? undefined
|
||||
: resolveTimerTimeoutMs(this.params.timeoutMs, MIN_CONNECT_TIMEOUT_MS);
|
||||
if (timeoutMs !== undefined) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.fail(new Error(`Proxy CONNECT timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupProxyListeners(): void {
|
||||
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);
|
||||
}
|
||||
|
||||
private cleanupTargetTlsListeners(): void {
|
||||
const socket = this.targetTlsSocket;
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
socket.off("secureConnect", this.onTargetSecureConnect);
|
||||
socket.off("error", this.fail);
|
||||
socket.off("close", this.onTargetTlsClosedBeforeSecureConnect);
|
||||
}
|
||||
|
||||
private readonly fail = (err: unknown): void => {
|
||||
if (this.settled) {
|
||||
return;
|
||||
}
|
||||
this.settled = true;
|
||||
this.clearTimer();
|
||||
this.cleanupProxyListeners();
|
||||
this.cleanupTargetTlsListeners();
|
||||
// Failure may happen during either CONNECT or target TLS setup. Destroy both
|
||||
// sockets so half-open proxy tunnels do not leak into the process.
|
||||
this.targetTlsSocket?.destroy();
|
||||
this.proxySocket?.destroy();
|
||||
this.reject(formatTunnelFailure(this.params.proxyUrl, err));
|
||||
};
|
||||
|
||||
private succeed(socket: tls.TLSSocket): void {
|
||||
if (this.settled) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
this.settled = true;
|
||||
this.clearTimer();
|
||||
this.cleanupProxyListeners();
|
||||
this.cleanupTargetTlsListeners();
|
||||
this.resolve(socket);
|
||||
}
|
||||
|
||||
private readonly onProxyConnected = (): void => {
|
||||
const socket = this.proxySocket;
|
||||
if (!socket) {
|
||||
this.fail(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);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onProxyData = (chunk: Buffer): void => {
|
||||
let result: ProxyConnectReadResult;
|
||||
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(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);
|
||||
};
|
||||
|
||||
private startTargetTls(socket: ProxySocket): void {
|
||||
try {
|
||||
this.targetTlsSocket = tls.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);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onTargetSecureConnect = (): void => {
|
||||
const socket = this.targetTlsSocket;
|
||||
if (!socket) {
|
||||
this.fail(new Error("APNs TLS socket missing after secureConnect"));
|
||||
return;
|
||||
}
|
||||
if (socket.alpnProtocol !== "h2") {
|
||||
const negotiated = socket.alpnProtocol || "no ALPN protocol";
|
||||
this.fail(new Error(`APNs TLS tunnel negotiated ${negotiated} instead of h2`));
|
||||
return;
|
||||
}
|
||||
this.succeed(socket);
|
||||
};
|
||||
|
||||
private readonly onTargetTlsClosedBeforeSecureConnect = (): void => {
|
||||
this.fail(new Error("APNs TLS tunnel closed before secureConnect"));
|
||||
};
|
||||
|
||||
private readonly onProxyClosedBeforeConnect = (): void => {
|
||||
this.fail(new Error("Proxy closed before CONNECT response"));
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a TLS-over-CONNECT tunnel and verifies the target negotiated HTTP/2. */
|
||||
export async function openHttpConnectTunnel(
|
||||
params: HttpConnectTunnelParams,
|
||||
): Promise<tls.TLSSocket> {
|
||||
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<tls.TLSSocket>((resolve, reject) => {
|
||||
new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start();
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
// Covers APNs HTTP/2 session and proxy behavior.
|
||||
import type http2 from "node:http2";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "./net/proxy/active-proxy-state.js";
|
||||
|
||||
type HttpConnectTunnelParams = Parameters<
|
||||
typeof import("./net/http-connect-tunnel.js").openHttpConnectTunnel
|
||||
type ProxyConnectTunnelParams = Parameters<
|
||||
typeof import("@openclaw/proxyline").openProxyConnectTunnel
|
||||
>[0];
|
||||
|
||||
const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
|
||||
const {
|
||||
connectSpy,
|
||||
tunnelSpy,
|
||||
tlsConnectSpy,
|
||||
setTargetTlsEvent,
|
||||
fakeProxySocket,
|
||||
fakeRequest,
|
||||
fakeSession,
|
||||
fakeTlsSocket,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeEmitter {
|
||||
private readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
@@ -36,6 +45,10 @@ const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.ho
|
||||
return this;
|
||||
}
|
||||
|
||||
removeListener(event: string, handler: (...args: unknown[]) => void): this {
|
||||
return this.off(event, handler);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
@@ -68,13 +81,40 @@ const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.ho
|
||||
}),
|
||||
request: vi.fn(() => fakeRequestLocal),
|
||||
});
|
||||
const fakeTlsSocketLocal = { encrypted: true };
|
||||
const fakeProxySocketLocal = { destroy: vi.fn() };
|
||||
const fakeTlsSocketLocal = Object.assign(new FakeEmitter(), {
|
||||
encrypted: true,
|
||||
alpnProtocol: "h2" as string | false,
|
||||
destroyed: false,
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
fakeTlsSocketLocal.destroy.mockImplementation(() => {
|
||||
fakeTlsSocketLocal.destroyed = true;
|
||||
});
|
||||
let targetTlsEvent: "secureConnect" | "close" | "error" | undefined = "secureConnect";
|
||||
return {
|
||||
fakeProxySocket: fakeProxySocketLocal,
|
||||
fakeRequest: fakeRequestLocal,
|
||||
fakeSession: fakeSessionLocal,
|
||||
fakeTlsSocket: fakeTlsSocketLocal,
|
||||
connectSpy: vi.fn(() => fakeSessionLocal),
|
||||
tunnelSpy: vi.fn(async (_params: HttpConnectTunnelParams) => fakeTlsSocketLocal),
|
||||
tunnelSpy: vi.fn(async (_params: ProxyConnectTunnelParams) => fakeProxySocketLocal),
|
||||
tlsConnectSpy: vi.fn(() => {
|
||||
const event = targetTlsEvent;
|
||||
if (event) {
|
||||
queueMicrotask(() => {
|
||||
if (event === "error") {
|
||||
fakeTlsSocketLocal.emit("error", new Error("target TLS failed"));
|
||||
} else {
|
||||
fakeTlsSocketLocal.emit(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
return fakeTlsSocketLocal;
|
||||
}),
|
||||
setTargetTlsEvent: (event: typeof targetTlsEvent) => {
|
||||
targetTlsEvent = event;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -84,11 +124,16 @@ vi.mock("node:http2", () => ({
|
||||
constants: { NGHTTP2_CANCEL: 8 },
|
||||
}));
|
||||
|
||||
vi.mock("./net/http-connect-tunnel.js", () => ({
|
||||
openHttpConnectTunnel: tunnelSpy,
|
||||
vi.mock("node:tls", () => ({
|
||||
default: { connect: tlsConnectSpy },
|
||||
connect: tlsConnectSpy,
|
||||
}));
|
||||
|
||||
function lastTunnelCall(): HttpConnectTunnelParams {
|
||||
vi.mock("@openclaw/proxyline", () => ({
|
||||
openProxyConnectTunnel: tunnelSpy,
|
||||
}));
|
||||
|
||||
function lastTunnelCall(): ProxyConnectTunnelParams {
|
||||
const calls = tunnelSpy.mock.calls;
|
||||
const call = calls[calls.length - 1];
|
||||
if (!call) {
|
||||
@@ -108,8 +153,12 @@ function lastConnectCall(): [string, http2.ClientSessionOptions] {
|
||||
|
||||
describe("connectApnsHttp2Session", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
connectSpy.mockClear();
|
||||
tunnelSpy.mockClear();
|
||||
tlsConnectSpy.mockClear();
|
||||
setTargetTlsEvent("secureConnect");
|
||||
fakeProxySocket.destroy.mockClear();
|
||||
fakeRequest.reset();
|
||||
fakeRequest.setEncoding.mockClear();
|
||||
fakeRequest.end.mockClear();
|
||||
@@ -119,7 +168,16 @@ describe("connectApnsHttp2Session", () => {
|
||||
fakeSession.close.mockClear();
|
||||
fakeSession.destroy.mockClear();
|
||||
fakeSession.request.mockClear();
|
||||
fakeTlsSocket.reset();
|
||||
fakeTlsSocket.alpnProtocol = "h2";
|
||||
fakeTlsSocket.destroyed = false;
|
||||
fakeTlsSocket.destroy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses direct http2.connect when managed proxy is inactive", async () => {
|
||||
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
|
||||
|
||||
@@ -188,6 +246,11 @@ describe("connectApnsHttp2Session", () => {
|
||||
expect(tunnelCall.targetHost).toBe("api.push.apple.com");
|
||||
expect(tunnelCall.targetPort).toBe(443);
|
||||
expect(tunnelCall.timeoutMs).toBe(10_000);
|
||||
expect(tlsConnectSpy).toHaveBeenCalledWith({
|
||||
socket: fakeProxySocket,
|
||||
servername: "api.push.apple.com",
|
||||
ALPNProtocols: ["h2"],
|
||||
});
|
||||
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||
const connectCall = lastConnectCall();
|
||||
expect(connectCall[0]).toBe("https://api.push.apple.com");
|
||||
@@ -196,6 +259,71 @@ describe("connectApnsHttp2Session", () => {
|
||||
expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket);
|
||||
});
|
||||
|
||||
it("rejects a non-h2 target tunnel without exposing proxy URL details", async () => {
|
||||
fakeTlsSocket.alpnProtocol = "http/1.1";
|
||||
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
|
||||
|
||||
const result = probeApnsHttp2ReachabilityViaProxy({
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
proxyUrl: "http://proxy.example:8080/private?detail=opaque#fragment",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: APNs TLS tunnel negotiated http/1.1 instead of h2",
|
||||
);
|
||||
const proxyUrl = lastTunnelCall().proxyUrl;
|
||||
expect(proxyUrl).toBeInstanceOf(URL);
|
||||
if (!(proxyUrl instanceof URL)) {
|
||||
throw new Error("expected normalized proxy URL");
|
||||
}
|
||||
expect(proxyUrl.pathname).toBe("/");
|
||||
expect(proxyUrl.search).toBe("");
|
||||
expect(proxyUrl.hash).toBe("");
|
||||
expect(String(await result.catch((error: unknown) => error))).not.toMatch(
|
||||
/private|opaque|fragment/,
|
||||
);
|
||||
expect(fakeTlsSocket.destroy).toHaveBeenCalledOnce();
|
||||
expect(fakeProxySocket.destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("times out the target TLS handshake within the CONNECT deadline", async () => {
|
||||
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
|
||||
const registration = registerActiveManagedProxyUrl(new URL("http://proxy.example:8080"), {
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
setTargetTlsEvent(undefined);
|
||||
|
||||
const result = connectApnsHttp2Session({
|
||||
authority: "https://api.push.apple.com",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const rejection = expect(result).rejects.toThrow(
|
||||
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1000ms",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tlsConnectSpy).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await rejection;
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
expect(fakeTlsSocket.destroy).toHaveBeenCalledOnce();
|
||||
expect(fakeProxySocket.destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects malformed proxy auth before opening the native tunnel", async () => {
|
||||
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
|
||||
|
||||
await expect(
|
||||
probeApnsHttp2ReachabilityViaProxy({
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
proxyUrl: "http://%E0%A4%A@proxy.example:8080",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: URI malformed");
|
||||
expect(tunnelSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps oversized managed proxy timeouts before opening the APNs tunnel", async () => {
|
||||
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.example:8443"), {
|
||||
loopbackMode: "gateway-only",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Opens APNs HTTP/2 sessions with optional managed proxy tunneling.
|
||||
import { once } from "node:events";
|
||||
import http2 from "node:http2";
|
||||
import tls from "node:tls";
|
||||
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import { openProxyConnectTunnel } from "@openclaw/proxyline";
|
||||
import { toErrorObject } from "./errors.js";
|
||||
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
|
||||
import {
|
||||
getActiveManagedProxyUrl,
|
||||
getActiveManagedProxyTlsOptions,
|
||||
@@ -77,6 +79,86 @@ function assertApnsAuthority(authority: string): ApnsAuthority {
|
||||
return normalized as ApnsAuthority;
|
||||
}
|
||||
|
||||
function normalizeConnectProxyUrl(proxyUrl: URL): URL {
|
||||
const normalized = new URL(proxyUrl);
|
||||
normalized.pathname = "/";
|
||||
normalized.search = "";
|
||||
normalized.hash = "";
|
||||
try {
|
||||
// Proxyline decodes auth from its socket callback. Validate first so bad
|
||||
// config rejects normally instead of escaping the EventEmitter boundary.
|
||||
decodeURIComponent(normalized.username);
|
||||
decodeURIComponent(normalized.password);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Proxy CONNECT failed via ${normalized.origin}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function openApnsTlsTunnel(params: {
|
||||
proxyUrl: URL;
|
||||
proxyTls?: ManagedProxyTlsOptions;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<tls.TLSSocket> {
|
||||
// CONNECT ignores URL paths. Strip path metadata before Proxyline sees it so
|
||||
// tokens embedded in a configured proxy URL cannot surface in errors.
|
||||
const proxyUrl = normalizeConnectProxyUrl(params.proxyUrl);
|
||||
const deadline = Date.now() + params.timeoutMs;
|
||||
const proxySocket = await openProxyConnectTunnel({
|
||||
proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
targetHost: params.targetHost,
|
||||
targetPort: params.targetPort,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
let targetTlsSocket: tls.TLSSocket | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
targetTlsSocket = tls.connect({
|
||||
socket: proxySocket,
|
||||
servername: params.targetHost,
|
||||
ALPNProtocols: ["h2"],
|
||||
});
|
||||
timeout = setTimeout(
|
||||
() => abortController.abort(new Error(`Proxy CONNECT timed out after ${params.timeoutMs}ms`)),
|
||||
Math.max(1, deadline - Date.now()),
|
||||
);
|
||||
timeout.unref?.();
|
||||
await Promise.race([
|
||||
once(targetTlsSocket, "secureConnect", { signal: abortController.signal }),
|
||||
once(targetTlsSocket, "close", { signal: abortController.signal }).then(() => {
|
||||
throw new Error("APNs TLS tunnel closed before secureConnect");
|
||||
}),
|
||||
]);
|
||||
if (targetTlsSocket.alpnProtocol !== "h2") {
|
||||
throw new Error(
|
||||
`APNs TLS tunnel negotiated ${targetTlsSocket.alpnProtocol || "no ALPN protocol"} instead of h2`,
|
||||
);
|
||||
}
|
||||
return targetTlsSocket;
|
||||
} catch (err) {
|
||||
targetTlsSocket?.destroy();
|
||||
proxySocket.destroy();
|
||||
const failure = abortController.signal.aborted ? abortController.signal.reason : err;
|
||||
throw new Error(
|
||||
`Proxy CONNECT failed via ${proxyUrl.origin}: ${failure instanceof Error ? failure.message : String(failure)}`,
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function openProxiedApnsHttp2Session(params: {
|
||||
authority: ApnsAuthority;
|
||||
proxyUrl: ActiveManagedProxyUrl;
|
||||
@@ -84,7 +166,7 @@ async function openProxiedApnsHttp2Session(params: {
|
||||
timeoutMs: number;
|
||||
}): Promise<http2.ClientHttp2Session> {
|
||||
const apnsHost = new URL(params.authority).hostname;
|
||||
const tlsSocket = await openHttpConnectTunnel({
|
||||
const tlsSocket = await openApnsTlsTunnel({
|
||||
proxyUrl: params.proxyUrl,
|
||||
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
|
||||
targetHost: apnsHost,
|
||||
|
||||
Reference in New Issue
Block a user