mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
feat: add APNs proxy validation probe
This commit is contained in:
@@ -23,7 +23,7 @@ captured blobs, and purge local capture data.
|
||||
```bash
|
||||
openclaw proxy start [--host <host>] [--port <port>]
|
||||
openclaw proxy run [--host <host>] [--port <port>] -- <cmd...>
|
||||
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--timeout-ms <ms>]
|
||||
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--apns-reachable] [--apns-authority <url>] [--timeout-ms <ms>]
|
||||
openclaw proxy coverage
|
||||
openclaw proxy sessions [--limit <count>]
|
||||
openclaw proxy query --preset <name> [--session <id>]
|
||||
@@ -40,7 +40,10 @@ before changing config. By default it verifies that a public destination succeed
|
||||
through the proxy and that the proxy cannot reach a temporary loopback canary.
|
||||
Custom denied destinations are fail-closed: HTTP responses and ambiguous
|
||||
transport failures both fail unless you can verify a deployment-specific denial
|
||||
signal separately.
|
||||
signal separately. Add `--apns-reachable` to also open an APNs HTTP/2 CONNECT
|
||||
tunnel through the proxy and confirm sandbox APNs responds; the probe uses an
|
||||
intentionally invalid provider token, so an APNs `403 InvalidProviderToken`
|
||||
response is a successful reachability signal.
|
||||
|
||||
Options:
|
||||
|
||||
@@ -48,6 +51,8 @@ Options:
|
||||
- `--proxy-url <url>`: validate this proxy URL instead of config or env.
|
||||
- `--allowed-url <url>`: add a destination expected to succeed through the proxy. Repeat to check multiple destinations.
|
||||
- `--denied-url <url>`: add a destination expected to be blocked by the proxy. Repeat to check multiple destinations.
|
||||
- `--apns-reachable`: also verify sandbox APNs HTTP/2 is reachable through the proxy.
|
||||
- `--apns-authority <url>`: APNs authority to probe with `--apns-reachable` (`https://api.sandbox.push.apple.com` by default; production is `https://api.push.apple.com`).
|
||||
- `--timeout-ms <ms>`: per-request timeout in milliseconds.
|
||||
|
||||
See [Network Proxy](/security/network-proxy) for deployment guidance and denial
|
||||
|
||||
@@ -139,7 +139,7 @@ Validate the proxy from the same host, container, or service account that runs O
|
||||
openclaw proxy validate --proxy-url http://127.0.0.1:3128
|
||||
```
|
||||
|
||||
By default, when no custom destinations are provided, the command checks that `https://example.com/` succeeds and starts a temporary loopback canary that the proxy must not reach. The default denied check passes when the proxy returns a non-2xx denial response or blocks the canary with a transport failure; it fails if a successful response reaches the canary. If no proxy is enabled and configured, validation reports a config problem; use `--proxy-url` for a one-off preflight before changing config. Use `--allowed-url` and `--denied-url` to test deployment-specific expectations. Custom denied destinations are fail-closed: any HTTP response means the destination was reachable through the proxy, and any transport error is reported as inconclusive because OpenClaw cannot prove the proxy blocked a reachable origin. On validation failure, the command exits with code 1.
|
||||
By default, when no custom destinations are provided, the command checks that `https://example.com/` succeeds and starts a temporary loopback canary that the proxy must not reach. The default denied check passes when the proxy returns a non-2xx denial response or blocks the canary with a transport failure; it fails if a successful response reaches the canary. If no proxy is enabled and configured, validation reports a config problem; use `--proxy-url` for a one-off preflight before changing config. Use `--allowed-url` and `--denied-url` to test deployment-specific expectations. Add `--apns-reachable` to also verify direct APNs HTTP/2 delivery can open a CONNECT tunnel through the proxy and receive a sandbox APNs response; the probe uses an intentionally invalid provider token, so `403 InvalidProviderToken` is expected and counts as reachable. Custom denied destinations are fail-closed: any HTTP response means the destination was reachable through the proxy, and any transport error is reported as inconclusive because OpenClaw cannot prove the proxy blocked a reachable origin. On validation failure, the command exits with code 1.
|
||||
|
||||
Use `--json` for automation. The JSON output contains the overall result, the effective proxy config source, any config errors, and each destination check. Proxy URL credentials are redacted in text and JSON output:
|
||||
|
||||
@@ -158,6 +158,12 @@ Use `--json` for automation. The JSON output contains the overall result, the ef
|
||||
"url": "https://example.com/",
|
||||
"ok": true,
|
||||
"status": 200
|
||||
},
|
||||
{
|
||||
"kind": "apns",
|
||||
"url": "https://api.sandbox.push.apple.com",
|
||||
"ok": true,
|
||||
"status": 403
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ describe("proxy cli runtime", () => {
|
||||
proxyUrl: "http://override.example:3128",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
apnsReachability: true,
|
||||
apnsAuthority: "https://api.sandbox.push.apple.com",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
|
||||
@@ -122,6 +124,8 @@ describe("proxy cli runtime", () => {
|
||||
proxyUrlOverride: "http://override.example:3128",
|
||||
allowedUrls: ["https://allowed.example/"],
|
||||
deniedUrls: ["http://127.0.0.1/"],
|
||||
apnsReachability: true,
|
||||
apnsAuthority: "https://api.sandbox.push.apple.com",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(
|
||||
|
||||
@@ -227,6 +227,8 @@ export async function runProxyValidateCommand(opts: {
|
||||
proxyUrl?: string;
|
||||
allowedUrls?: string[];
|
||||
deniedUrls?: string[];
|
||||
apnsReachability?: boolean;
|
||||
apnsAuthority?: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const config = getRuntimeConfig();
|
||||
@@ -236,6 +238,8 @@ export async function runProxyValidateCommand(opts: {
|
||||
proxyUrlOverride: opts.proxyUrl,
|
||||
allowedUrls: opts.allowedUrls,
|
||||
deniedUrls: opts.deniedUrls,
|
||||
apnsReachability: opts.apnsReachability,
|
||||
apnsAuthority: opts.apnsAuthority,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const outputResult = redactProxyValidationResult(result);
|
||||
|
||||
@@ -26,6 +26,8 @@ describe("proxy cli", () => {
|
||||
"--proxy-url",
|
||||
"--allowed-url",
|
||||
"--denied-url",
|
||||
"--apns-reachable",
|
||||
"--apns-authority",
|
||||
"--timeout-ms",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ export function registerProxyCli(program: Command) {
|
||||
collectOption,
|
||||
)
|
||||
.option("--denied-url <url>", "Destination expected to be blocked by the proxy", collectOption)
|
||||
.option("--apns-reachable", "Also verify sandbox APNs HTTP/2 is reachable through the proxy")
|
||||
.option("--apns-authority <url>", "APNs authority to probe with --apns-reachable")
|
||||
.option("--timeout-ms <ms>", "Per-request timeout in milliseconds", parseOptionalNumber)
|
||||
.action(
|
||||
async (opts: {
|
||||
@@ -74,6 +76,8 @@ export function registerProxyCli(program: Command) {
|
||||
proxyUrl?: string;
|
||||
allowedUrl?: string[];
|
||||
deniedUrl?: string[];
|
||||
apnsReachable?: boolean;
|
||||
apnsAuthority?: string;
|
||||
timeoutMs?: number;
|
||||
}) => {
|
||||
const runtime = await loadProxyCliRuntime();
|
||||
@@ -82,6 +86,8 @@ export function registerProxyCli(program: Command) {
|
||||
proxyUrl: opts.proxyUrl,
|
||||
allowedUrls: opts.allowedUrl,
|
||||
deniedUrls: opts.deniedUrl,
|
||||
apnsReachability: opts.apnsReachable,
|
||||
apnsAuthority: opts.apnsAuthority,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -420,4 +420,72 @@ describe("proxy validation", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds an APNs reachability check when requested", async () => {
|
||||
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const apnsCheck = vi.fn().mockResolvedValue({ status: 403 });
|
||||
|
||||
const result = await runProxyValidation({
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
env: {},
|
||||
allowedUrls: [],
|
||||
deniedUrls: [],
|
||||
apnsReachability: true,
|
||||
apnsAuthority: "https://api.sandbox.push.apple.com",
|
||||
timeoutMs: 1234,
|
||||
fetchCheck,
|
||||
apnsCheck,
|
||||
});
|
||||
|
||||
expect(fetchCheck).not.toHaveBeenCalled();
|
||||
expect(apnsCheck).toHaveBeenCalledWith({
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
source: "config",
|
||||
errors: [],
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
kind: "apns",
|
||||
url: "https://api.sandbox.push.apple.com",
|
||||
ok: true,
|
||||
status: 403,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails APNs reachability when the proxy blocks CONNECT", async () => {
|
||||
const result = await runProxyValidation({
|
||||
config: {
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
env: {},
|
||||
allowedUrls: [],
|
||||
deniedUrls: [],
|
||||
apnsReachability: true,
|
||||
apnsCheck: vi.fn().mockRejectedValue(new Error("HTTP/1.1 403 Forbidden")),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks).toEqual([
|
||||
{
|
||||
kind: "apns",
|
||||
url: "https://api.sandbox.push.apple.com",
|
||||
ok: false,
|
||||
error: "HTTP/1.1 403 Forbidden",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
import { probeApnsHttp2ReachabilityViaProxy } from "../../push-apns-http2.js";
|
||||
import { fetchWithRuntimeDispatcher } from "../runtime-fetch.js";
|
||||
import { createHttp1ProxyAgent } from "../undici-runtime.js";
|
||||
|
||||
export const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"] as const;
|
||||
export const DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY = "https://api.sandbox.push.apple.com";
|
||||
|
||||
const DEFAULT_PROXY_VALIDATION_TIMEOUT_MS = 5000;
|
||||
const DENIED_CANARY_HEADER = "x-openclaw-proxy-validation-canary";
|
||||
@@ -18,7 +20,7 @@ export type ProxyValidationResolvedConfig = {
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type ProxyValidationCheckKind = "allowed" | "denied";
|
||||
export type ProxyValidationCheckKind = "allowed" | "denied" | "apns";
|
||||
|
||||
export type ProxyValidationCheck = {
|
||||
kind: ProxyValidationCheckKind;
|
||||
@@ -50,6 +52,20 @@ export type ProxyValidationFetchCheck = (
|
||||
params: ProxyValidationFetchCheckParams,
|
||||
) => Promise<ProxyValidationFetchCheckResult>;
|
||||
|
||||
export type ProxyValidationApnsCheckParams = {
|
||||
proxyUrl: string;
|
||||
authority: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type ProxyValidationApnsCheckResult = {
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type ProxyValidationApnsCheck = (
|
||||
params: ProxyValidationApnsCheckParams,
|
||||
) => Promise<ProxyValidationApnsCheckResult>;
|
||||
|
||||
export type ResolveProxyValidationConfigOptions = {
|
||||
config?: ProxyConfig;
|
||||
env?: NodeJS.ProcessEnv | Partial<Record<"OPENCLAW_PROXY_URL", string | undefined>>;
|
||||
@@ -61,6 +77,9 @@ export type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & {
|
||||
deniedUrls?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
fetchCheck?: ProxyValidationFetchCheck;
|
||||
apnsReachability?: boolean;
|
||||
apnsAuthority?: string;
|
||||
apnsCheck?: ProxyValidationApnsCheck;
|
||||
};
|
||||
|
||||
function normalizeProxyUrl(value: string | undefined): string | undefined {
|
||||
@@ -176,6 +195,15 @@ async function defaultProxyValidationFetchCheck({
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultProxyValidationApnsCheck({
|
||||
proxyUrl,
|
||||
authority,
|
||||
timeoutMs,
|
||||
}: ProxyValidationApnsCheckParams): Promise<ProxyValidationApnsCheckResult> {
|
||||
const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, authority, timeoutMs });
|
||||
return { status: result.status };
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: number | undefined): number {
|
||||
if (value === undefined || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_PROXY_VALIDATION_TIMEOUT_MS;
|
||||
@@ -380,6 +408,34 @@ async function runDeniedCheck(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runApnsReachabilityCheck(params: {
|
||||
authority: string;
|
||||
proxyUrl: string;
|
||||
timeoutMs: number;
|
||||
apnsCheck: ProxyValidationApnsCheck;
|
||||
}): Promise<ProxyValidationCheck> {
|
||||
try {
|
||||
const result = await params.apnsCheck({
|
||||
proxyUrl: params.proxyUrl,
|
||||
authority: params.authority,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return {
|
||||
kind: "apns",
|
||||
url: params.authority,
|
||||
ok: true,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
kind: "apns",
|
||||
url: params.authority,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProxyValidation(
|
||||
options: RunProxyValidationOptions,
|
||||
): Promise<ProxyValidationResult> {
|
||||
@@ -405,6 +461,8 @@ export async function runProxyValidation(
|
||||
|
||||
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
||||
const fetchCheck = options.fetchCheck ?? defaultProxyValidationFetchCheck;
|
||||
const apnsCheck = options.apnsCheck ?? defaultProxyValidationApnsCheck;
|
||||
const apnsAuthority = options.apnsAuthority ?? DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY;
|
||||
const allowedUrls = options.allowedUrls ?? DEFAULT_PROXY_VALIDATION_ALLOWED_URLS;
|
||||
const deniedTargets = await resolveDeniedTargets(options.deniedUrls);
|
||||
const checks: ProxyValidationCheck[] = [];
|
||||
@@ -418,6 +476,16 @@ export async function runProxyValidation(
|
||||
await runDeniedCheck({ target, proxyUrl: config.proxyUrl, timeoutMs, fetchCheck }),
|
||||
);
|
||||
}
|
||||
if (options.apnsReachability === true) {
|
||||
checks.push(
|
||||
await runApnsReachabilityCheck({
|
||||
authority: apnsAuthority,
|
||||
proxyUrl: config.proxyUrl,
|
||||
timeoutMs,
|
||||
apnsCheck,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await deniedTargets.close();
|
||||
}
|
||||
|
||||
@@ -6,10 +6,66 @@ import {
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "./net/proxy/active-proxy-state.js";
|
||||
|
||||
const { connectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
|
||||
const fakeSession = { close: vi.fn(), destroy: vi.fn() };
|
||||
const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
|
||||
class FakeEmitter {
|
||||
private readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void): this {
|
||||
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: (...args: unknown[]) => void): this {
|
||||
const wrapped = (...args: unknown[]) => {
|
||||
this.off(event, wrapped);
|
||||
handler(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
off(event: string, handler: (...args: unknown[]) => void): this {
|
||||
this.handlers.set(
|
||||
event,
|
||||
(this.handlers.get(event) ?? []).filter((candidate) => candidate !== handler),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const fakeRequest = Object.assign(new FakeEmitter(), {
|
||||
setEncoding: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
fakeRequest.emit("response", { ":status": 403 });
|
||||
fakeRequest.emit("data", '{"reason":"InvalidProviderToken"}');
|
||||
fakeRequest.emit("end");
|
||||
});
|
||||
}),
|
||||
});
|
||||
const fakeSession = Object.assign(new FakeEmitter(), {
|
||||
closed: false,
|
||||
destroyed: false,
|
||||
close: vi.fn(() => {
|
||||
fakeSession.closed = true;
|
||||
}),
|
||||
destroy: vi.fn(() => {
|
||||
fakeSession.destroyed = true;
|
||||
}),
|
||||
request: vi.fn(() => fakeRequest),
|
||||
});
|
||||
const fakeTlsSocket = { encrypted: true };
|
||||
return {
|
||||
fakeRequest,
|
||||
fakeSession,
|
||||
fakeTlsSocket,
|
||||
connectSpy: vi.fn(() => fakeSession),
|
||||
@@ -31,6 +87,15 @@ describe("connectApnsHttp2Session", () => {
|
||||
beforeEach(() => {
|
||||
connectSpy.mockClear();
|
||||
tunnelSpy.mockClear();
|
||||
fakeRequest.reset();
|
||||
fakeRequest.setEncoding.mockClear();
|
||||
fakeRequest.end.mockClear();
|
||||
fakeSession.reset();
|
||||
fakeSession.closed = false;
|
||||
fakeSession.destroyed = false;
|
||||
fakeSession.close.mockClear();
|
||||
fakeSession.destroy.mockClear();
|
||||
fakeSession.request.mockClear();
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
});
|
||||
it("uses direct http2.connect when managed proxy is inactive", async () => {
|
||||
@@ -95,8 +160,36 @@ describe("connectApnsHttp2Session", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("probes APNs reachability through an explicit proxy", async () => {
|
||||
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
|
||||
|
||||
const result = await probeApnsHttp2ReachabilityViaProxy({
|
||||
authority: "https://api.sandbox.push.apple.com",
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: 403, body: '{"reason":"InvalidProviderToken"}' });
|
||||
expect(tunnelSpy).toHaveBeenCalledWith({
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
targetHost: "api.sandbox.push.apple.com",
|
||||
targetPort: 443,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
expect(fakeSession.request).toHaveBeenCalledWith({
|
||||
":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",
|
||||
});
|
||||
expect(fakeSession.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects non-APNs authorities", async () => {
|
||||
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
|
||||
const { connectApnsHttp2Session, probeApnsHttp2ReachabilityViaProxy } =
|
||||
await import("./push-apns-http2.js");
|
||||
|
||||
await expect(
|
||||
connectApnsHttp2Session({
|
||||
@@ -104,5 +197,12 @@ describe("connectApnsHttp2Session", () => {
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow("Unsupported APNs authority");
|
||||
await expect(
|
||||
probeApnsHttp2ReachabilityViaProxy({
|
||||
authority: "https://example.com",
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow("Unsupported APNs authority");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,17 @@ export type ConnectApnsHttp2SessionParams = {
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type ProbeApnsHttp2ReachabilityViaProxyParams = {
|
||||
authority: string;
|
||||
proxyUrl: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type ProbeApnsHttp2ReachabilityViaProxyResult = {
|
||||
status: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function assertApnsAuthority(authority: string): ApnsAuthority {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -30,6 +41,24 @@ function assertApnsAuthority(authority: string): ApnsAuthority {
|
||||
return normalized as ApnsAuthority;
|
||||
}
|
||||
|
||||
async function openProxiedApnsHttp2Session(params: {
|
||||
authority: ApnsAuthority;
|
||||
proxyUrl: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<http2.ClientHttp2Session> {
|
||||
const apnsHost = new URL(params.authority).hostname;
|
||||
const tlsSocket = await openHttpConnectTunnel({
|
||||
proxyUrl: params.proxyUrl,
|
||||
targetHost: apnsHost,
|
||||
targetPort: 443,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
|
||||
return http2.connect(params.authority, {
|
||||
createConnection: () => tlsSocket,
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectApnsHttp2Session(
|
||||
params: ConnectApnsHttp2SessionParams,
|
||||
): Promise<http2.ClientHttp2Session> {
|
||||
@@ -39,15 +68,86 @@ export async function connectApnsHttp2Session(
|
||||
return http2.connect(authority);
|
||||
}
|
||||
|
||||
const apnsHost = new URL(authority).hostname;
|
||||
const tlsSocket = await openHttpConnectTunnel({
|
||||
return await openProxiedApnsHttp2Session({
|
||||
authority,
|
||||
proxyUrl,
|
||||
targetHost: apnsHost,
|
||||
targetPort: 443,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeApnsHttp2ReachabilityViaProxy(
|
||||
params: ProbeApnsHttp2ReachabilityViaProxyParams,
|
||||
): Promise<ProbeApnsHttp2ReachabilityViaProxyResult> {
|
||||
const authority = assertApnsAuthority(params.authority);
|
||||
const session = await openProxiedApnsHttp2Session({
|
||||
authority,
|
||||
proxyUrl: params.proxyUrl,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
|
||||
return http2.connect(authority, {
|
||||
createConnection: () => tlsSocket,
|
||||
});
|
||||
try {
|
||||
return await new Promise<ProbeApnsHttp2ReachabilityViaProxyResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let body = "";
|
||||
let status: number | undefined;
|
||||
const timeout = setTimeout(() => {
|
||||
fail(
|
||||
new Error(`APNs reachability probe timed out after ${Math.trunc(params.timeoutMs)}ms`),
|
||||
);
|
||||
}, Math.trunc(params.timeoutMs));
|
||||
timeout.unref?.();
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
session.off("error", fail);
|
||||
};
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
session.destroy(err instanceof Error ? err : new Error(String(err)));
|
||||
reject(err);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
request.on("data", (chunk) => {
|
||||
body += String(chunk);
|
||||
});
|
||||
request.once("error", fail);
|
||||
request.once("end", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (status === undefined || !Number.isFinite(status)) {
|
||||
reject(new Error("APNs reachability probe ended without an HTTP/2 status"));
|
||||
return;
|
||||
}
|
||||
resolve({ status, body });
|
||||
});
|
||||
request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } }));
|
||||
});
|
||||
} finally {
|
||||
if (!session.closed && !session.destroyed) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user