diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index f60f52b685a..58d937b29db 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -23,7 +23,7 @@ captured blobs, and purge local capture data. ```bash openclaw proxy start [--host ] [--port ] openclaw proxy run [--host ] [--port ] -- -openclaw proxy validate [--json] [--proxy-url ] [--allowed-url ] [--denied-url ] [--timeout-ms ] +openclaw proxy validate [--json] [--proxy-url ] [--allowed-url ] [--denied-url ] [--apns-reachable] [--apns-authority ] [--timeout-ms ] openclaw proxy coverage openclaw proxy sessions [--limit ] openclaw proxy query --preset [--session ] @@ -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 `: validate this proxy URL instead of config or env. - `--allowed-url `: add a destination expected to succeed through the proxy. Repeat to check multiple destinations. - `--denied-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 `: APNs authority to probe with `--apns-reachable` (`https://api.sandbox.push.apple.com` by default; production is `https://api.push.apple.com`). - `--timeout-ms `: per-request timeout in milliseconds. See [Network Proxy](/security/network-proxy) for deployment guidance and denial diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 72a26b9cc52..741e4c4badc 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -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 } ] } diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index 2f591090842..f38cee36762 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -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( diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index 5547dc0ed58..c3ded290d08 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -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); diff --git a/src/cli/proxy-cli.test.ts b/src/cli/proxy-cli.test.ts index 4a16c352e66..c4c694af560 100644 --- a/src/cli/proxy-cli.test.ts +++ b/src/cli/proxy-cli.test.ts @@ -26,6 +26,8 @@ describe("proxy cli", () => { "--proxy-url", "--allowed-url", "--denied-url", + "--apns-reachable", + "--apns-authority", "--timeout-ms", ]); }); diff --git a/src/cli/proxy-cli.ts b/src/cli/proxy-cli.ts index a3fe580e0b4..30f133637d4 100644 --- a/src/cli/proxy-cli.ts +++ b/src/cli/proxy-cli.ts @@ -67,6 +67,8 @@ export function registerProxyCli(program: Command) { collectOption, ) .option("--denied-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 ", "APNs authority to probe with --apns-reachable") .option("--timeout-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, }); }, diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index 16f84ff9bce..6732e91db25 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -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", + }, + ]); + }); }); diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index e7dca108d5a..87d96e16e4f 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -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; +export type ProxyValidationApnsCheckParams = { + proxyUrl: string; + authority: string; + timeoutMs: number; +}; + +export type ProxyValidationApnsCheckResult = { + status: number; +}; + +export type ProxyValidationApnsCheck = ( + params: ProxyValidationApnsCheckParams, +) => Promise; + export type ResolveProxyValidationConfigOptions = { config?: ProxyConfig; env?: NodeJS.ProcessEnv | Partial>; @@ -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 { + 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 { + 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 { @@ -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(); } diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index 79a69a6d54b..db57510fa3c 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -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 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"); }); }); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index 68eea7bcc61..2cd7663a6fd 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -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 { + 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 { @@ -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 { + 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((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(); + } + } }