From d5b0083300a61d31d4a57864bfd394f825120592 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 21:04:17 +1000 Subject: [PATCH] fix: proxy direct APNs HTTP2 sessions (#74905) Summary: - This PR routes direct APNs HTTP/2 sends through an APNs allowlisted managed-proxy CONNECT wrapper, adds APNs proxy validation/docs/guardrails, and expands regression and live-test coverage. - Reproducibility: yes. source-reproducible: current main `sendApnsRequest()` still uses raw `http2.connect(au ... nly covers HTTP/global-agent/Undici hooks. I did not run a live APNs reproduction in this read-only review. Automerge notes: - PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 APNs connections - PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 with OpenGrep - PR branch already contained follow-up commit before automerge: lint: ban raw HTTP2 imports - PR branch already contained follow-up commit before automerge: fix: use managed proxy state for APNs - PR branch already contained follow-up commit before automerge: test: exercise APNs active proxy state - PR branch already contained follow-up commit before automerge: fix: reject conflicting managed proxy activation Validation: - ClawSweeper review passed for head dab7c86a7595a01b09c32395578e7c26a03f938d. - Required merge gates passed before the squash merge. Prepared head SHA: dab7c86a7595a01b09c32395578e7c26a03f938d Review: https://github.com/openclaw/openclaw/pull/74905#issuecomment-4350181159 Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- .../openclaw-live-and-e2e-checks-reusable.yml | 7 + CHANGELOG.md | 1 + docs/cli/proxy.md | 9 +- docs/help/testing-live.md | 9 + docs/security/network-proxy.md | 8 +- package.json | 3 +- scripts/check-no-raw-http2-imports.mjs | 120 +++++++ scripts/run-additional-boundary-checks.mjs | 1 + scripts/test-live-shard.mjs | 3 + security/opengrep/precise.yml | 40 ++- .../openclaw-policy/no-raw-http2-connect.yml | 32 ++ src/cli/proxy-cli.runtime.test.ts | 71 +++- src/cli/proxy-cli.runtime.ts | 69 +++- src/cli/proxy-cli.test.ts | 2 + src/cli/proxy-cli.ts | 6 + src/infra/net/http-connect-tunnel.test.ts | 308 +++++++++++++++++ src/infra/net/http-connect-tunnel.ts | 315 ++++++++++++++++++ src/infra/net/proxy/active-proxy-state.ts | 52 +++ src/infra/net/proxy/proxy-lifecycle.test.ts | 59 +++- src/infra/net/proxy/proxy-lifecycle.ts | 78 ++--- src/infra/net/proxy/proxy-validation.test.ts | 142 ++++++++ src/infra/net/proxy/proxy-validation.ts | 109 +++++- src/infra/push-apns-http2.live.test.ts | 129 +++++++ src/infra/push-apns-http2.test.ts | 251 ++++++++++++++ src/infra/push-apns-http2.ts | 176 ++++++++++ src/infra/push-apns.test.ts | 222 ++++++++++++ src/infra/push-apns.ts | 10 +- .../package-acceptance-workflow.test.ts | 5 + .../run-additional-boundary-checks.test.ts | 8 + test/scripts/test-live-shard.test.ts | 3 + 30 files changed, 2159 insertions(+), 89 deletions(-) create mode 100644 scripts/check-no-raw-http2-imports.mjs create mode 100644 security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml create mode 100644 src/infra/net/http-connect-tunnel.test.ts create mode 100644 src/infra/net/http-connect-tunnel.ts create mode 100644 src/infra/net/proxy/active-proxy-state.ts create mode 100644 src/infra/push-apns-http2.live.test.ts create mode 100644 src/infra/push-apns-http2.test.ts create mode 100644 src/infra/push-apns-http2.ts diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 63ac6e6b033..1c8f7924619 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -409,6 +409,7 @@ jobs: add_profile_suite native-live-src-gateway-profiles-xai "full" add_profile_suite native-live-src-gateway-profiles-zai "full" add_profile_suite native-live-src-gateway-backends "stable full" + add_profile_suite native-live-src-infra "stable full" add_profile_suite native-live-test "stable full" add_profile_suite native-live-extensions-l-n "full" add_profile_suite native-live-extensions-moonshot "full" @@ -1989,6 +1990,12 @@ jobs: timeout_minutes: 90 profile_env_only: false profiles: stable full + - suite_id: native-live-src-infra + label: Native live infra + command: OPENCLAW_LIVE_APNS_REACHABILITY=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-infra + timeout_minutes: 45 + profile_env_only: false + profiles: stable full - suite_id: native-live-test label: Native live test harnesses command: node .release-harness/scripts/test-live-shard.mjs native-live-test diff --git a/CHANGELOG.md b/CHANGELOG.md index 82612f874de..544049021f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -278,6 +278,7 @@ Docs: https://docs.openclaw.ai - Slack: collapse routine Socket Mode pong-timeout reconnects into one OpenClaw reconnect line and suppress the duplicate Slack SDK pong warning. - Gateway/diagnostics: abort-drain embedded runs after an extended no-progress stall so a single dead session no longer leaves queued Discord/channel turns blocked behind repeated `recovery=none` liveness warnings. - Plugins/ClawHub: accept the live artifact resolver `kind`/`sha256` field names alongside the typed `artifactKind`/`artifactSha256` form so `clawhub:` installs of npm-pack and legacy ZIP packages no longer miss downloadable artifacts. Thanks @romneyda. +- Direct APNs: route direct HTTP/2 delivery through the active managed proxy with redacted proxy diagnostics, so push requests honor configured egress controls and `openclaw proxy validate --apns-reachable` can prove APNs is reachable through the proxy before deployment. (#74905) Thanks @jesse-merhi. - Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc. - Gateway/watch: run `doctor --fix --non-interactive` once and retry when the dev Gateway child exits during startup, so stale local plugin install/config state does not leave the tmux watch session disappearing without a repair attempt. - Doctor/Telegram: warn when selected Telegram quote replies can suppress `streaming.preview.toolProgress`, and document the `replyToMode` trade-off without changing runtime delivery. Fixes #73487. Thanks @GodsBoy. 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/help/testing-live.md b/docs/help/testing-live.md index 8e03361896b..ffaf18902cc 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -203,6 +203,15 @@ Notes: - The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI. - Claude's default smoke also patches the session from Sonnet to Opus and verifies the resumed session still remembers an earlier note. +## Live: APNs HTTP/2 proxy reachability + +- Test: `src/infra/push-apns-http2.live.test.ts` +- Goal: tunnel through a local HTTP CONNECT proxy to Apple's sandbox APNs endpoint, send the APNs HTTP/2 validation request, and assert Apple's real `403 InvalidProviderToken` response comes back through the proxy path. +- Enable: + - `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_APNS_REACHABILITY=1 pnpm test:live src/infra/push-apns-http2.live.test.ts` +- Optional timeout: + - `OPENCLAW_LIVE_APNS_TIMEOUT_MS=30000` + ## Live: ACP bind smoke (`/acp spawn ... --bind here`) - Test: `src/gateway/gateway-acp-bind.live.test.ts` 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/package.json b/package.json index db57bd378b0..5551e9b955b 100644 --- a/package.json +++ b/package.json @@ -1420,12 +1420,13 @@ "lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts", "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", "lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs", - "lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts", + "lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts", "lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", + "lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs", "lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs", diff --git a/scripts/check-no-raw-http2-imports.mjs b/scripts/check-no-raw-http2-imports.mjs new file mode 100644 index 00000000000..5c1b2c8c7d9 --- /dev/null +++ b/scripts/check-no-raw-http2-imports.mjs @@ -0,0 +1,120 @@ +import fs from "node:fs"; +import path from "node:path"; +const SOURCE_ROOTS = ["src", "extensions"]; +const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]); + +function isCodeFile(filePath) { + if (filePath.endsWith(".d.ts")) { + return false; + } + return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); +} + +function collectFilesSync(rootDir, options) { + const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES; + const files = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!skipDirNames.has(entry.name)) { + stack.push(fullPath); + } + continue; + } + if (entry.isFile() && options.includeFile(fullPath)) { + files.push(fullPath); + } + } + } + + return files; +} + +function toPosixPath(filePath) { + return filePath.replaceAll("\\", "/"); +} + +const FORBIDDEN_HTTP2_MODULES = new Set(["node:http2", "http2"]); +const ALLOWED_PRODUCTION_FILES = new Set(["src/infra/push-apns-http2.ts"]); + +function isTestFile(relativePath) { + return ( + /(?:^|\/)(?:test|test-fixtures)\//u.test(relativePath) || + /\.test\.[cm]?[jt]sx?$/u.test(relativePath) + ); +} + +function lineNumberForOffset(content, offset) { + return content.slice(0, offset).split(/\r?\n/u).length; +} + +function collectHttp2ImportOffenders(filePath) { + const relativePath = toPosixPath(path.relative(process.cwd(), filePath)); + if (ALLOWED_PRODUCTION_FILES.has(relativePath) || isTestFile(relativePath)) { + return []; + } + + const content = fs.readFileSync(filePath, "utf8"); + const offenders = []; + const patterns = [ + /\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu, + /\bexport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/gu, + ]; + + for (const pattern of patterns) { + for (const match of content.matchAll(pattern)) { + const specifier = match[1]; + if (specifier && FORBIDDEN_HTTP2_MODULES.has(specifier)) { + offenders.push({ + file: relativePath, + line: lineNumberForOffset(content, match.index ?? 0), + specifier, + }); + } + } + } + + return offenders; +} + +function collectSourceFiles() { + return SOURCE_ROOTS.flatMap((root) => + collectFilesSync(path.join(process.cwd(), root), { + includeFile: isCodeFile, + }), + ); +} + +function main() { + const offenders = collectSourceFiles().flatMap(collectHttp2ImportOffenders); + if (offenders.length === 0) { + console.log("OK: raw node:http2 imports stay behind the APNs proxy wrapper."); + return; + } + + console.error("Raw node:http2 imports are only allowed in src/infra/push-apns-http2.ts."); + for (const offender of offenders.toSorted( + (a, b) => a.file.localeCompare(b.file) || a.line - b.line, + )) { + console.error(`- ${offender.file}:${offender.line} imports ${offender.specifier}`); + } + console.error("Use connectApnsHttp2Session() so APNs HTTP/2 honors managed proxy policy."); + process.exit(1); +} + +main(); diff --git a/scripts/run-additional-boundary-checks.mjs b/scripts/run-additional-boundary-checks.mjs index 8b656edf292..b40d9a7fb99 100644 --- a/scripts/run-additional-boundary-checks.mjs +++ b/scripts/run-additional-boundary-checks.mjs @@ -9,6 +9,7 @@ export const BOUNDARY_CHECKS = [ ["lint:tmp:channel-agnostic-boundaries", "pnpm", ["run", "lint:tmp:channel-agnostic-boundaries"]], ["lint:tmp:tsgo-core-boundary", "pnpm", ["run", "lint:tmp:tsgo-core-boundary"]], ["lint:tmp:no-raw-channel-fetch", "pnpm", ["run", "lint:tmp:no-raw-channel-fetch"]], + ["lint:tmp:no-raw-http2-imports", "pnpm", ["run", "lint:tmp:no-raw-http2-imports"]], ["lint:agent:ingress-owner", "pnpm", ["run", "lint:agent:ingress-owner"]], [ "lint:plugins:no-register-http-handler", diff --git a/scripts/test-live-shard.mjs b/scripts/test-live-shard.mjs index 5f7ae4528f7..c0f62c859c3 100644 --- a/scripts/test-live-shard.mjs +++ b/scripts/test-live-shard.mjs @@ -11,6 +11,7 @@ export const RELEASE_LIVE_TEST_SHARDS = Object.freeze([ "native-live-src-gateway-core", "native-live-src-gateway-profiles", "native-live-src-gateway-backends", + "native-live-src-infra", "native-live-test", "native-live-extensions-a-k", "native-live-extensions-l-n", @@ -154,6 +155,8 @@ export function selectLiveShardFiles(shard, files = collectAllLiveTestFiles()) { return files.filter(isGatewayProfilesLiveTest); case "native-live-src-gateway-backends": return files.filter(isGatewayBackendLiveTest); + case "native-live-src-infra": + return files.filter((file) => file.startsWith("src/infra/")); case "native-live-test": return files.filter((file) => file.startsWith("test/")); case "native-live-extensions-a-k": diff --git a/security/opengrep/precise.yml b/security/opengrep/precise.yml index 836cbe0a9ae..99f874c9053 100644 --- a/security/opengrep/precise.yml +++ b/security/opengrep/precise.yml @@ -3,9 +3,9 @@ # Auto-generated by security/opengrep/compile-rules.mjs. # DO NOT EDIT BY HAND. Re-run the compile script after editing source rules. # -# Source rules dir: -# Generated at : 2026-04-29T07:10:35.427Z -# Rule count : 147 +# Source rules dir: security/opengrep/rules/openclaw-policy +# Generated at : 2026-04-30T09:09:41.198Z +# Rule count : 148 rules: - id: ghsa-25gx-x37c-7pph.openclaw-novnc-x11vnc-missing-auth message: x11vnc starts without VNC authentication; avoid -nopw and require password auth when exposing noVNC observer access. @@ -4976,3 +4976,37 @@ rules: - pattern-not-inside: | import { resolvePathWithinRoot, ... } from "$X"; ... + - id: openclaw-policy-raw-http2-connect.no-raw-http2-connect + languages: + - typescript + - javascript + severity: ERROR + message: Use connectApnsHttp2Session() from src/infra/push-apns-http2.ts instead of raw http2.connect() so APNs HTTP/2 honors managed proxy policy. + metadata: + advisory-id: OPENCLAW-POLICY-RAW-HTTP2-CONNECT + advisory-url: https://github.com/openclaw/openclaw/pull/74905 + cwe: + - CWE-441 + category: security + confidence: HIGH + detector-bucket: precise + source-rule-id: no-raw-http2-connect + source-file: security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml + paths: + include: + - src/**/*.ts + - src/**/*.mts + - src/**/*.js + - src/**/*.mjs + - extensions/**/*.ts + - extensions/**/*.mts + - extensions/**/*.js + - extensions/**/*.mjs + exclude: + - src/infra/push-apns-http2.ts + - "**/*.test.ts" + - "**/*.test.mts" + - "**/*.test.js" + - "**/*.test.mjs" + patterns: + - pattern: http2.connect(...) diff --git a/security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml b/security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml new file mode 100644 index 00000000000..531f876f2ac --- /dev/null +++ b/security/opengrep/rules/openclaw-policy/no-raw-http2-connect.yml @@ -0,0 +1,32 @@ +rules: + - id: no-raw-http2-connect + languages: + - typescript + - javascript + severity: ERROR + message: Use connectApnsHttp2Session() from src/infra/push-apns-http2.ts instead of raw http2.connect() so APNs HTTP/2 honors managed proxy policy. + metadata: + advisory-id: OPENCLAW-POLICY-RAW-HTTP2-CONNECT + advisory-url: https://github.com/openclaw/openclaw/pull/74905 + cwe: + - "CWE-441" + category: security + confidence: HIGH + paths: + include: + - "src/**/*.ts" + - "src/**/*.mts" + - "src/**/*.js" + - "src/**/*.mjs" + - "extensions/**/*.ts" + - "extensions/**/*.mts" + - "extensions/**/*.js" + - "extensions/**/*.mjs" + exclude: + - "src/infra/push-apns-http2.ts" + - "**/*.test.ts" + - "**/*.test.mts" + - "**/*.test.js" + - "**/*.test.mjs" + patterns: + - pattern: http2.connect(...) diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index 2f591090842..e1784420b83 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -43,6 +43,8 @@ describe("proxy cli runtime", () => { "OPENCLAW_DEBUG_PROXY_CERT_DIR", "OPENCLAW_DEBUG_PROXY_SESSION_ID", "OPENCLAW_DEBUG_PROXY_ENABLED", + "FORCE_COLOR", + "NO_COLOR", ] as const; const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); let tempDir = ""; @@ -54,6 +56,8 @@ describe("proxy cli runtime", () => { process.env.OPENCLAW_DEBUG_PROXY_CERT_DIR = path.join(tempDir, "certs"); delete process.env.OPENCLAW_DEBUG_PROXY_ENABLED; delete process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID; + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; getRuntimeConfigMock.mockReset(); getRuntimeConfigMock.mockReturnValue({ proxy: { @@ -109,6 +113,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 +128,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( @@ -307,6 +315,66 @@ describe("proxy cli runtime", () => { ); }); + it("prints check errors on the same line", async () => { + runProxyValidationMock.mockResolvedValueOnce({ + ok: true, + config: { + enabled: true, + proxyUrl: "http://proxy.example:3128", + source: "config", + errors: [], + }, + checks: [ + { + kind: "denied", + url: "http://127.0.0.1:12345/", + ok: true, + error: "fetch failed", + }, + ], + }); + const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js"); + + await runProxyValidateCommand({}); + + expect(process.stdout.write).toHaveBeenCalledWith( + "Proxy validation passed\n\n" + + "Proxy\n" + + " Source: config\n" + + " URL: http://proxy.example:3128/\n\n" + + "Checks\n" + + " ✓ denied http://127.0.0.1:12345/ — fetch failed\n", + ); + }); + + it("applies the terminal color theme when rich output is enabled", async () => { + vi.resetModules(); + vi.doMock("../terminal/theme.js", () => ({ + colorize: (rich: boolean, color: (value: string) => string, value: string) => + rich ? color(value) : value, + isRich: () => true, + theme: { + heading: (value: string) => `${value}`, + success: (value: string) => `${value}`, + error: (value: string) => `${value}`, + muted: (value: string) => `${value}`, + warn: (value: string) => `${value}`, + }, + })); + try { + const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js"); + + await runProxyValidateCommand({}); + + const output = String(vi.mocked(process.stdout.write).mock.calls[0]?.[0] ?? ""); + expect(output).toContain("Proxy validation passed"); + expect(output).toContain("Checks"); + expect(output).toContain(""); + } finally { + vi.doUnmock("../terminal/theme.js"); + } + }); + it("prints actionable check failure output", async () => { runProxyValidationMock.mockResolvedValueOnce({ ok: false, @@ -343,8 +411,7 @@ describe("proxy cli runtime", () => { " URL: http://proxy.example:3128/\n\n" + "Checks\n" + " ✓ allowed http://target.example/allowed HTTP 200\n" + - " ✗ denied http://target.example/allowed HTTP 200\n" + - " Denied destination was reachable through the proxy\n\n" + + " ✗ denied http://target.example/allowed HTTP 200 — Denied destination was reachable through the proxy\n\n" + "Next steps\n" + " Update the proxy ACL so denied destinations are blocked, or pass the expected --denied-url values.\n", ); diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index 5547dc0ed58..a7299385d08 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -19,6 +19,7 @@ import { getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; import type { CaptureQueryPreset } from "../proxy-capture/types.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) { const settings = resolveDebugProxySettings(); @@ -148,11 +149,41 @@ function redactProxyValidationResult(result: ProxyValidationResult): ProxyValida }; } -function formatProxyCheckLine(check: ProxyValidationResult["checks"][number]): string { - const icon = check.ok ? "✓" : "✗"; - const paddedKind = check.kind.padEnd(7, " "); - const status = check.status === undefined ? "" : ` HTTP ${check.status}`; - return ` ${icon} ${paddedKind} ${check.url}${status}`; +type ProxyValidationTextColors = { + heading: (value: string) => string; + success: (value: string) => string; + error: (value: string) => string; + muted: (value: string) => string; + warn: (value: string) => string; +}; + +function getProxyValidationTextColors(): ProxyValidationTextColors { + const rich = isRich(); + const apply = (color: (value: string) => string) => (value: string) => + colorize(rich, color, value); + return { + heading: apply(theme.heading), + success: apply(theme.success), + error: apply(theme.error), + muted: apply(theme.muted), + warn: apply(theme.warn), + }; +} + +function formatProxyCheckLine( + check: ProxyValidationResult["checks"][number], + colors: ProxyValidationTextColors, +): string { + const icon = check.ok ? colors.success("✓") : colors.error("✗"); + const paddedKind = colors.muted(check.kind.padEnd(7, " ")); + const status = + check.status === undefined + ? "" + : ` ${check.ok ? colors.success(`HTTP ${check.status}`) : colors.error(`HTTP ${check.status}`)}`; + const detail = check.error + ? ` — ${check.ok ? colors.muted(check.error) : colors.error(check.error)}` + : ""; + return ` ${icon} ${paddedKind} ${check.url}${status}${detail}`; } function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] { @@ -185,37 +216,35 @@ function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] } function formatProxyValidationText(result: ProxyValidationResult): string { + const colors = getProxyValidationTextColors(); const redactedProxyUrl = redactProxyUrl(result.config.proxyUrl); const lines = [ - `Proxy validation ${result.ok ? "passed" : "failed"}`, + result.ok ? colors.success("Proxy validation passed") : colors.error("Proxy validation failed"), "", - "Proxy", - ` Source: ${result.config.source}`, - ` URL: ${redactedProxyUrl ?? "not configured"}`, + colors.heading("Proxy"), + ` Source: ${colors.muted(result.config.source)}`, + ` URL: ${redactedProxyUrl ?? colors.muted("not configured")}`, ]; if (result.config.errors.length > 0) { - lines.push("", "Problems"); + lines.push("", colors.heading("Problems")); for (const error of result.config.errors) { - lines.push(` - ${error}`); + lines.push(` - ${colors.error(error)}`); } } if (result.checks.length > 0) { - lines.push("", "Checks"); + lines.push("", colors.heading("Checks")); for (const check of result.checks) { - lines.push(formatProxyCheckLine(check)); - if (check.error) { - lines.push(` ${check.error}`); - } + lines.push(formatProxyCheckLine(check, colors)); } } const nextSteps = formatProxyValidationNextSteps(result); if (nextSteps.length > 0) { - lines.push("", "Next steps"); + lines.push("", colors.heading("Next steps")); for (const nextStep of nextSteps) { - lines.push(` ${nextStep}`); + lines.push(` ${colors.warn(nextStep)}`); } } @@ -227,6 +256,8 @@ export async function runProxyValidateCommand(opts: { proxyUrl?: string; allowedUrls?: string[]; deniedUrls?: string[]; + apnsReachability?: boolean; + apnsAuthority?: string; timeoutMs?: number; }) { const config = getRuntimeConfig(); @@ -236,6 +267,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/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts new file mode 100644 index 00000000000..337340570c8 --- /dev/null +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -0,0 +1,308 @@ +import { EventEmitter } from "node:events"; +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, +})); + +vi.mock("node:tls", () => ({ + connect: tlsConnectSpy, +})); + +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"), + targetHost: "api.sandbox.push.apple.com", + targetPort: 443, + }); + + expect(tlsConnectSpy.mock.calls[0]?.[0]).toEqual({ + host: "proxy.example", + port: 8443, + servername: "proxy.example", + ALPNProtocols: ["http/1.1"], + }); + expect(tlsConnectSpy).toHaveBeenLastCalledWith({ + socket: proxySocket, + servername: "api.sandbox.push.apple.com", + ALPNProtocols: ["h2"], + }); + }); + + 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 () => { + const proxySocket = new FakeSocket(); + 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, + timeoutMs: 1, + }), + ).rejects.toThrow( + "Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1ms", + ); + expect(proxySocket.destroyed).toBe(true); + }); +}); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts new file mode 100644 index 00000000000..5a21af1b8f8 --- /dev/null +++ b/src/infra/net/http-connect-tunnel.ts @@ -0,0 +1,315 @@ +import * as net from "node:net"; +import * as tls from "node:tls"; + +export type HttpConnectTunnelParams = { + proxyUrl: URL; + targetHost: string; + targetPort: number; + timeoutMs?: number; +}; + +const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024; + +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 ""; + } +} + +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 { + 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] ?? ""; + 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): ProxySocket { + const proxyHost = resolveProxyHost(proxy); + const connectOptions = { + host: proxyHost, + port: resolveProxyPort(proxy), + }; + if (proxy.protocol === "https:") { + return tls.connect({ + ...connectOptions, + servername: proxyHost, + ALPNProtocols: ["http/1.1"], + }); + } + 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.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; + if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) { + this.timeout = setTimeout(() => { + this.fail(new Error(`Proxy CONNECT timed out after ${Math.trunc(timeoutMs)}ms`)); + }, Math.trunc(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(); + 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")); + }; +} + +export async function openHttpConnectTunnel( + params: HttpConnectTunnelParams, +): Promise { + 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(); + }); +} diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts new file mode 100644 index 00000000000..d884397a4b7 --- /dev/null +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -0,0 +1,52 @@ +export type ActiveManagedProxyUrl = Readonly; + +export type ActiveManagedProxyRegistration = { + proxyUrl: ActiveManagedProxyUrl; + stopped: boolean; +}; + +let activeProxyUrl: ActiveManagedProxyUrl | undefined; +let activeProxyRegistrationCount = 0; + +export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxyRegistration { + const normalizedProxyUrl = new URL(proxyUrl.href); + if (activeProxyUrl !== undefined) { + if (activeProxyUrl.href !== normalizedProxyUrl.href) { + throw new Error( + "proxy: cannot activate a managed proxy while another proxy is active; " + + "stop the current proxy before changing proxy.proxyUrl.", + ); + } + activeProxyRegistrationCount += 1; + return { proxyUrl: activeProxyUrl, stopped: false }; + } + + activeProxyUrl = normalizedProxyUrl; + activeProxyRegistrationCount = 1; + return { proxyUrl: activeProxyUrl, stopped: false }; +} + +export function stopActiveManagedProxyRegistration( + registration: ActiveManagedProxyRegistration, +): void { + if (registration.stopped) { + return; + } + registration.stopped = true; + if (activeProxyUrl?.href !== registration.proxyUrl.href) { + return; + } + activeProxyRegistrationCount = Math.max(0, activeProxyRegistrationCount - 1); + if (activeProxyRegistrationCount === 0) { + activeProxyUrl = undefined; + } +} + +export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined { + return activeProxyUrl; +} + +export function _resetActiveManagedProxyStateForTests(): void { + activeProxyUrl = undefined; + activeProxyRegistrationCount = 0; +} diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index e98215418f9..bbb60417856 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -19,6 +19,7 @@ vi.mock("../../../logger.js", () => ({ import { bootstrap as bootstrapGlobalAgent } from "global-agent"; import { logInfo, logWarn } from "../../../logger.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; +import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js"; import { _resetGlobalAgentBootstrapForTests, dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane, @@ -66,6 +67,7 @@ describe("startProxy", () => { mockLogInfo.mockReset(); mockLogWarn.mockReset(); _resetGlobalAgentBootstrapForTests(); + _resetActiveManagedProxyStateForTests(); (global as Record)["GLOBAL_AGENT"] = undefined; http.request = originalHttpRequest; http.get = originalHttpGet; @@ -113,6 +115,23 @@ describe("startProxy", () => { expect(mockLogWarn).not.toHaveBeenCalled(); }); + it("exposes the active managed proxy URL", async () => { + const { getActiveManagedProxyUrl } = await import("./active-proxy-state.js"); + + expect(getActiveManagedProxyUrl()).toBeUndefined(); + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(getActiveManagedProxyUrl()?.href).toBe("http://127.0.0.1:3128/"); + + await stopProxy(handle); + + expect(getActiveManagedProxyUrl()).toBeUndefined(); + }); + it("uses OPENCLAW_PROXY_URL when config proxyUrl is omitted", async () => { process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; @@ -272,7 +291,7 @@ describe("startProxy", () => { expect((global as Record)["GLOBAL_AGENT"]).toBeUndefined(); }); - it("keeps process-wide proxy hooks active until the last overlapping handle stops", async () => { + it("keeps same-url overlapping handles active until the final stop", async () => { const patchedHttpRequest = vi.fn() as unknown as typeof http.request; const patchedHttpGet = vi.fn() as unknown as typeof http.get; const patchedHttpsRequest = vi.fn() as unknown as typeof https.request; @@ -294,22 +313,25 @@ describe("startProxy", () => { }); const secondHandle = await startProxy({ enabled: true, - proxyUrl: "http://127.0.0.1:3129", + proxyUrl: "http://127.0.0.1:3128", }); + expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce(); + expect(mockBootstrapGlobalAgent).toHaveBeenCalledOnce(); expect(http.request).toBe(patchedHttpRequest); expect(https.request).toBe(patchedHttpsRequest); - expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); - - await stopProxy(firstHandle); - - expect(http.request).toBe(patchedHttpRequest); - expect(https.request).toBe(patchedHttpsRequest); - expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129"); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); await stopProxy(secondHandle); + expect(http.request).toBe(patchedHttpRequest); + expect(https.request).toBe(patchedHttpsRequest); + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); + + await stopProxy(firstHandle); + expect(http.request).toBe(originalHttpRequest); expect(http.get).toBe(originalHttpGet); expect(https.request).toBe(originalHttpsRequest); @@ -318,6 +340,25 @@ describe("startProxy", () => { expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined(); }); + it("rejects overlapping handles with different managed proxy URLs", async () => { + const firstHandle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + await expect( + startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3129", + }), + ).rejects.toThrow("cannot activate a managed proxy"); + + expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); + expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1"); + + await stopProxy(firstHandle); + }); + it("restores env and throws when undici activation fails", async () => { mockForceResetGlobalDispatcher.mockImplementationOnce(() => { throw new Error("dispatcher failed"); diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 7b6ffe2fe88..98f57d1fcec 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -15,6 +15,12 @@ import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; import { logInfo, logWarn } from "../../../logger.js"; import { isLoopbackIpAddress } from "../../../shared/net/ip.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; +import { + getActiveManagedProxyUrl, + registerActiveManagedProxyUrl, + stopActiveManagedProxyRegistration, + type ActiveManagedProxyRegistration, +} from "./active-proxy-state.js"; export type ProxyHandle = { /** The operator-managed proxy URL injected into process.env. */ @@ -64,10 +70,6 @@ type NodeHttpStackSnapshot = { hadGlobalAgent: boolean; globalAgent: unknown; }; -type ActiveProxyRegistration = { - proxyUrl: string; - stopped: boolean; -}; type GlobalAgentConnectConfiguration = Record & { host: string; tls: Record; @@ -82,14 +84,12 @@ type GlobalAgentHttpsAgent = { let globalAgentBootstrapped = false; let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null; -let activeProxyRegistrations: ActiveProxyRegistration[] = []; let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null; let patchedGlobalAgentHttpsAgents = new WeakSet(); export function _resetGlobalAgentBootstrapForTests(): void { globalAgentBootstrapped = false; nodeHttpStackSnapshot = null; - activeProxyRegistrations = []; baseProxyEnvSnapshot = null; patchedGlobalAgentHttpsAgents = new WeakSet(); } @@ -302,16 +302,6 @@ function patchGlobalAgentHttpsConnectTlsTargetHost(): void { patchedGlobalAgentHttpsAgents.add(agent); } -function findTopActiveProxyRegistration(): ActiveProxyRegistration | null { - for (let index = activeProxyRegistrations.length - 1; index >= 0; index -= 1) { - const registration = activeProxyRegistrations[index]; - if (!registration.stopped) { - return registration; - } - } - return null; -} - function resetUndiciDispatcherForProxyLifecycle(): void { try { forceResetGlobalDispatcher(); @@ -336,16 +326,6 @@ function restoreNodeHttpStackForProxyLifecycle(): void { } } -function reapplyActiveProxyRuntime(proxyUrl: string): void { - applyProxyEnv(proxyUrl); - resetUndiciDispatcherForProxyLifecycle(); - try { - bootstrapNodeHttpStack(proxyUrl); - } catch (err) { - logWarn(`proxy: failed to refresh node HTTP proxy hooks: ${String(err)}`); - } -} - function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void { restoreProxyEnv(snapshot); resetUndiciDispatcherForProxyLifecycle(); @@ -353,28 +333,17 @@ function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void { restoreNodeHttpStackForProxyLifecycle(); } -function restoreAfterFailedProxyActivation( - previousActiveRegistration: ActiveProxyRegistration | null, - restoreSnapshot: ProxyEnvSnapshot, -): void { - if (previousActiveRegistration) { - reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl); - return; - } +function restoreAfterFailedProxyActivation(restoreSnapshot: ProxyEnvSnapshot): void { restoreInactiveProxyRuntime(restoreSnapshot); baseProxyEnvSnapshot = null; } -function stopActiveProxyRegistration(registration: ActiveProxyRegistration): void { +function stopActiveProxyRegistration(registration: ActiveManagedProxyRegistration): void { if (registration.stopped) { return; } - registration.stopped = true; - activeProxyRegistrations = activeProxyRegistrations.filter((entry) => !entry.stopped); - - const nextActiveRegistration = findTopActiveProxyRegistration(); - if (nextActiveRegistration) { - reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl); + stopActiveManagedProxyRegistration(registration); + if (getActiveManagedProxyUrl()) { return; } @@ -424,23 +393,34 @@ export async function startProxy(config: ProxyConfig | undefined): Promise { + stopActiveProxyRegistration(registration); + }, + kill: () => { + stopActiveProxyRegistration(registration); + }, + }; + return handle; + } baseProxyEnvSnapshot ??= captureProxyEnv(); const lifecycleBaseEnvSnapshot = baseProxyEnvSnapshot; let injectedEnvSnapshot = captureProxyEnv(); - let registration: ActiveProxyRegistration | null = null; + let registration: ActiveManagedProxyRegistration | null = null; try { injectedEnvSnapshot = injectProxyEnv(proxyUrl); forceResetGlobalDispatcher(); bootstrapNodeHttpStack(proxyUrl); - registration = { - proxyUrl, - stopped: false, - }; - activeProxyRegistrations.push(registration); + registration = registerActiveManagedProxyUrl(new URL(proxyUrl)); } catch (err) { - restoreAfterFailedProxyActivation(previousActiveRegistration, lifecycleBaseEnvSnapshot); + restoreAfterFailedProxyActivation(lifecycleBaseEnvSnapshot); throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, { cause: err, }); diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index 16f84ff9bce..4c0402b9eef 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -420,4 +420,146 @@ 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, apnsId: "00000000-0000-0000-0000-000000000000" }); + + 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("accepts APNs 403 reachability with InvalidProviderToken when apns-id is unavailable", async () => { + const result = await runProxyValidation({ + config: { + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }, + env: {}, + allowedUrls: [], + deniedUrls: [], + apnsReachability: true, + apnsCheck: vi.fn().mockResolvedValue({ status: 403, apnsReason: "InvalidProviderToken" }), + }); + + expect(result.ok).toBe(true); + expect(result.checks).toEqual([ + { + kind: "apns", + url: "https://api.sandbox.push.apple.com", + ok: true, + status: 403, + }, + ]); + }); + + it("fails APNs reachability when bare 403 has no APNs proof", async () => { + const result = await runProxyValidation({ + config: { + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }, + env: {}, + allowedUrls: [], + deniedUrls: [], + apnsReachability: true, + apnsCheck: vi.fn().mockResolvedValue({ status: 403 }), + }); + + expect(result.ok).toBe(false); + expect(result.checks).toEqual([ + { + kind: "apns", + url: "https://api.sandbox.push.apple.com", + ok: false, + error: expect.stringContaining("InvalidProviderToken"), + }, + ]); + }); + + it("fails APNs reachability when non-403 response has no apns-id (proxy intercept)", async () => { + const result = await runProxyValidation({ + config: { + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }, + env: {}, + allowedUrls: [], + deniedUrls: [], + apnsReachability: true, + apnsCheck: vi.fn().mockResolvedValue({ status: 200 }), + }); + + expect(result.ok).toBe(false); + expect(result.checks).toEqual([ + { + kind: "apns", + url: "https://api.sandbox.push.apple.com", + ok: false, + error: expect.stringContaining("apns-id"), + }, + ]); + }); + + 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..82083716e47 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -1,13 +1,16 @@ 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"; +const APNS_REACHABILITY_REASON = "InvalidProviderToken"; export type ProxyValidationConfigSource = "override" | "config" | "env" | "missing" | "disabled"; @@ -18,7 +21,7 @@ export type ProxyValidationResolvedConfig = { errors: string[]; }; -export type ProxyValidationCheckKind = "allowed" | "denied"; +export type ProxyValidationCheckKind = "allowed" | "denied" | "apns"; export type ProxyValidationCheck = { kind: ProxyValidationCheckKind; @@ -50,6 +53,24 @@ export type ProxyValidationFetchCheck = ( params: ProxyValidationFetchCheckParams, ) => Promise; +export type ProxyValidationApnsCheckParams = { + proxyUrl: string; + authority: string; + timeoutMs: number; +}; + +export type ProxyValidationApnsCheckResult = { + status: number; + /** Present when the response originated from a real APNs server (Apple always returns this UUID). */ + apnsId?: string; + /** APNs JSON error reason. InvalidProviderToken proves the invalid-token probe reached APNs. */ + apnsReason?: string; +}; + +export type ProxyValidationApnsCheck = ( + params: ProxyValidationApnsCheckParams, +) => Promise; + export type ResolveProxyValidationConfigOptions = { config?: ProxyConfig; env?: NodeJS.ProcessEnv | Partial>; @@ -61,6 +82,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 +200,39 @@ async function defaultProxyValidationFetchCheck({ } } +async function defaultProxyValidationApnsCheck({ + proxyUrl, + authority, + timeoutMs, +}: ProxyValidationApnsCheckParams): Promise { + const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, authority, timeoutMs }); + return { + status: result.status, + apnsId: result.responseHeaders?.["apns-id"], + apnsReason: parseApnsErrorReason(result.body), + }; +} + +function parseApnsErrorReason(body: string): string | undefined { + try { + const parsed: unknown = JSON.parse(body); + if (!parsed || typeof parsed !== "object") { + return undefined; + } + const reason = (parsed as { reason?: unknown }).reason; + return typeof reason === "string" && reason.trim() ? reason : undefined; + } catch { + return undefined; + } +} + +function hasApnsReachabilityProof(result: ProxyValidationApnsCheckResult): boolean { + if (result.apnsId) { + return true; + } + return result.status === 403 && result.apnsReason === APNS_REACHABILITY_REASON; +} + function normalizeTimeoutMs(value: number | undefined): number { if (value === undefined || !Number.isFinite(value) || value <= 0) { return DEFAULT_PROXY_VALIDATION_TIMEOUT_MS; @@ -380,6 +437,44 @@ 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, + }); + if (!hasApnsReachabilityProof(result)) { + return { + kind: "apns", + url: params.authority, + ok: false, + error: + "APNs reachability check failed: response did not include an apns-id header or APNs InvalidProviderToken body. " + + "The proxy may be intercepting the connection instead of tunneling it.", + }; + } + 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 +500,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 +515,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.live.test.ts b/src/infra/push-apns-http2.live.test.ts new file mode 100644 index 00000000000..25cae547e64 --- /dev/null +++ b/src/infra/push-apns-http2.live.test.ts @@ -0,0 +1,129 @@ +import { createServer, type Server } from "node:http"; +import { connect } from "node:net"; +import { afterAll, describe, expect, it } from "vitest"; +import { isTruthyEnvValue } from "./env.js"; +import { probeApnsHttp2ReachabilityViaProxy } from "./push-apns-http2.js"; + +const APNS_SANDBOX_AUTHORITY = "https://api.sandbox.push.apple.com"; +const APNS_SANDBOX_HOST = "api.sandbox.push.apple.com"; +const APNS_CONNECT_PORT = 443; +const DEFAULT_TIMEOUT_MS = 15_000; + +const LIVE = + (isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST)) && + isTruthyEnvValue(process.env.OPENCLAW_LIVE_APNS_REACHABILITY); +const describeLive = LIVE ? describe : describe.skip; + +function getLiveTimeoutMs(): number { + const raw = process.env.OPENCLAW_LIVE_APNS_TIMEOUT_MS; + if (!raw) { + return DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`OPENCLAW_LIVE_APNS_TIMEOUT_MS must be a positive number, got ${raw}`); + } + return Math.trunc(parsed); +} + +function parseConnectTarget(target: string): { hostname: string; port: number } | undefined { + try { + const parsed = new URL(`http://${target}`); + const port = parsed.port ? Number(parsed.port) : APNS_CONNECT_PORT; + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + return undefined; + } + return { hostname: parsed.hostname, port }; + } catch { + return undefined; + } +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function startApnsConnectProxy(): Promise<{ proxyUrl: string; server: Server }> { + const server = createServer((_request, response) => { + response.writeHead(405); + response.end(); + }); + + server.on("connect", (request, clientSocket, head) => { + const target = request.url ? parseConnectTarget(request.url) : undefined; + if (!target || target.hostname !== APNS_SANDBOX_HOST || target.port !== APNS_CONNECT_PORT) { + clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + clientSocket.destroy(); + return; + } + + const upstreamSocket = connect(target.port, target.hostname); + upstreamSocket.once("connect", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + upstreamSocket.write(head); + } + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + }); + upstreamSocket.once("error", () => { + clientSocket.destroy(); + }); + clientSocket.once("error", () => { + upstreamSocket.destroy(); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("APNs live CONNECT proxy did not bind to a TCP port"); + } + + return { + proxyUrl: `http://127.0.0.1:${address.port}`, + server, + }; +} + +describeLive("APNs HTTP/2 live reachability via CONNECT proxy", () => { + const servers: Server[] = []; + + afterAll(async () => { + await Promise.all(servers.map((server) => closeServer(server))); + }); + + it( + "receives Apple's 403 response through the HTTP/2 CONNECT tunnel", + async () => { + const { proxyUrl, server } = await startApnsConnectProxy(); + servers.push(server); + + const result = await probeApnsHttp2ReachabilityViaProxy({ + authority: APNS_SANDBOX_AUTHORITY, + proxyUrl, + timeoutMs: getLiveTimeoutMs(), + }); + + expect(result.status).toBe(403); + expect(result.body).toContain("InvalidProviderToken"); + }, + getLiveTimeoutMs() + 5_000, + ); +}); diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts new file mode 100644 index 00000000000..6c96e36057f --- /dev/null +++ b/src/infra/push-apns-http2.test.ts @@ -0,0 +1,251 @@ +import type http2 from "node:http2"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HttpConnectTunnelParams } from "./net/http-connect-tunnel.js"; +import { + _resetActiveManagedProxyStateForTests, + registerActiveManagedProxyUrl, + stopActiveManagedProxyRegistration, +} from "./net/proxy/active-proxy-state.js"; + +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), + tunnelSpy: vi.fn(async (_params: HttpConnectTunnelParams) => fakeTlsSocket), + }; +}); + +vi.mock("node:http2", () => ({ + default: { connect: connectSpy, constants: { NGHTTP2_CANCEL: 8 } }, + connect: connectSpy, + constants: { NGHTTP2_CANCEL: 8 }, +})); + +vi.mock("./net/http-connect-tunnel.js", () => ({ + openHttpConnectTunnel: tunnelSpy, +})); + +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 () => { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.sandbox.push.apple.com", + timeoutMs: 10_000, + }); + + expect(session).toBe(fakeSession); + expect(tunnelSpy).not.toHaveBeenCalled(); + expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com"); + }); + + it("normalizes the default APNs HTTPS port", async () => { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + await connectApnsHttp2Session({ + authority: "https://api.push.apple.com:443", + timeoutMs: 10_000, + }); + + expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com"); + }); + + it("rejects APNs authorities with non-origin URL components", async () => { + const { connectApnsHttp2Session, probeApnsHttp2ReachabilityViaProxy } = + await import("./push-apns-http2.js"); + + await expect( + connectApnsHttp2Session({ + authority: "https://token@api.push.apple.com", + timeoutMs: 10_000, + }), + ).rejects.toThrow("Unsupported APNs authority"); + await expect( + probeApnsHttp2ReachabilityViaProxy({ + authority: "https://api.sandbox.push.apple.com/3/device/abc", + proxyUrl: "http://proxy.example:8080", + timeoutMs: 10_000, + }), + ).rejects.toThrow("Unsupported APNs authority"); + }); + + it("uses an HTTP CONNECT tunnel when managed proxy is active", async () => { + const registration = registerActiveManagedProxyUrl(new URL("http://proxy.example:8080")); + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.push.apple.com", + timeoutMs: 10_000, + }); + stopActiveManagedProxyRegistration(registration); + + expect(session).toBe(fakeSession); + const tunnelCall = tunnelSpy.mock.calls.at(-1)?.[0]; + const proxyUrl = tunnelCall?.proxyUrl; + expect(proxyUrl).toBeInstanceOf(URL); + if (!(proxyUrl instanceof URL)) { + throw new Error("expected active managed proxy URL"); + } + expect(proxyUrl.href).toBe("http://proxy.example:8080/"); + expect(tunnelCall?.targetHost).toBe("api.push.apple.com"); + expect(tunnelCall?.targetPort).toBe(443); + expect(tunnelCall?.timeoutMs).toBe(10_000); + expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com", { + createConnection: expect.any(Function), + }); + const connectCall = connectSpy.mock.calls.at(-1) as + | [string, http2.ClientSessionOptions] + | undefined; + const createConnection = connectCall?.[1].createConnection; + expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket); + }); + + it("ignores ambient proxy env when managed proxy is inactive", async () => { + const originalHttpsProxy = process.env["HTTPS_PROXY"]; + process.env["HTTPS_PROXY"] = "http://ambient.example:8080"; + try { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.push.apple.com", + timeoutMs: 10_000, + }); + + expect(session).toBe(fakeSession); + expect(tunnelSpy).not.toHaveBeenCalled(); + } finally { + if (originalHttpsProxy === undefined) { + delete process.env["HTTPS_PROXY"]; + } else { + process.env["HTTPS_PROXY"] = originalHttpsProxy; + } + } + }); + + 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"}', + responseHeaders: {}, + }); + const tunnelCall = tunnelSpy.mock.calls.at(-1)?.[0]; + const proxyUrl = tunnelCall?.proxyUrl; + expect(proxyUrl).toBeInstanceOf(URL); + if (!(proxyUrl instanceof URL)) { + throw new Error("expected explicit proxy URL"); + } + expect(proxyUrl.href).toBe("http://proxy.example:8080/"); + expect(tunnelCall?.targetHost).toBe("api.sandbox.push.apple.com"); + expect(tunnelCall?.targetPort).toBe(443); + expect(tunnelCall?.timeoutMs).toBe(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, probeApnsHttp2ReachabilityViaProxy } = + await import("./push-apns-http2.js"); + + await expect( + connectApnsHttp2Session({ + authority: "https://example.com", + 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 new file mode 100644 index 00000000000..32cdbe45150 --- /dev/null +++ b/src/infra/push-apns-http2.ts @@ -0,0 +1,176 @@ +import http2 from "node:http2"; +import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js"; +import { + getActiveManagedProxyUrl, + type ActiveManagedProxyUrl, +} from "./net/proxy/active-proxy-state.js"; + +const APNS_DEFAULT_PORT = "443"; + +const APNS_AUTHORITIES = new Set([ + "https://api.push.apple.com", + "https://api.sandbox.push.apple.com", +]); + +type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.apple.com"; + +export const APNS_HTTP2_CANCEL_CODE = http2.constants.NGHTTP2_CANCEL; + +export type ConnectApnsHttp2SessionParams = { + authority: string; + timeoutMs: number; +}; + +export type ProbeApnsHttp2ReachabilityViaProxyParams = { + authority: string; + proxyUrl: string; + timeoutMs: number; +}; + +export type ProbeApnsHttp2ReachabilityViaProxyResult = { + status: number; + body: string; + /** Raw response headers from APNs. Includes apns-id when the connection was truly tunneled to Apple. */ + responseHeaders: Record; +}; + +function assertApnsAuthority(authority: string): ApnsAuthority { + let parsed: URL; + 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 as ApnsAuthority; +} + +async function openProxiedApnsHttp2Session(params: { + authority: ApnsAuthority; + proxyUrl: ActiveManagedProxyUrl; + 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 { + const authority = assertApnsAuthority(params.authority); + const proxyUrl = getActiveManagedProxyUrl(); + if (!proxyUrl) { + return http2.connect(authority); + } + + return await openProxiedApnsHttp2Session({ + authority, + proxyUrl, + timeoutMs: params.timeoutMs, + }); +} + +export async function probeApnsHttp2ReachabilityViaProxy( + params: ProbeApnsHttp2ReachabilityViaProxyParams, +): Promise { + const authority = assertApnsAuthority(params.authority); + const session = await openProxiedApnsHttp2Session({ + authority, + proxyUrl: new URL(params.proxyUrl), + timeoutMs: params.timeoutMs, + }); + + try { + return await new Promise((resolve, reject) => { + let settled = false; + let body = ""; + let status: number | undefined; + let responseHeaders: Record = {}; + 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); + responseHeaders = Object.fromEntries( + Object.entries(headers) + .filter(([k]) => !k.startsWith(":")) + .map(([k, v]) => [k, String(v)]), + ); + }); + 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, responseHeaders }); + }); + request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } })); + }); + } finally { + if (!session.closed && !session.destroyed) { + session.close(); + } + } +} diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 5c60388db89..12e90ff4bbe 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,5 +1,9 @@ import { generateKeyPairSync } from "node:crypto"; +import { createServer, type Server as HttpServer } from "node:http"; +import http2 from "node:http2"; +import net from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { startProxy, stopProxy, type ProxyHandle } from "./net/proxy/proxy-lifecycle.js"; import { sendApnsAlert, sendApnsBackgroundWake, @@ -11,6 +15,67 @@ const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1", }).privateKey.export({ format: "pem", type: "pkcs8" }); +const testApnsServerKey = `-----BEGIN PRIVATE KEY-----`; // pragma: allowlist secret +const testApnsServerKeyPem = `${testApnsServerKey} +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1l/DDGxT//Ma2 +1EC7ON4lb+9IOrHHd437rv5DBhMt7ZXpzmfZuXyJWd/RI3ljiCcJeXwTYdzLsyaR +aMRUnbzOoaI5/9LRdwmo007Y/US1ZxSjXW3L+vl3+QtiAUt6GDBZo49jB/LSCgu3 +lXYcN96OjpkF2j8rBR8Sn7eTUMIkiCFKn8V68hMRhDuHVJHWSGsMcfq8P7jZZ8S0 +31sUvQw8JaAvEhju3GbxbhQH8RnicR4VxI+bZ3v1JTnWNXCSClRmfDAM0AFrWv8k +qJXrhat4RsppeRSRDjENdUFS+VvW2s/oyaU9hXl3/G+9Srx5ANOCdLy+pTQdkq3b +Clg7a917AgMBAAECggEACpyyZolJ7PtiyeMI7pTQSp2XFrOZzKw8bgJk4oBtSE66 +AMIqruSx/Fbch3Zl81gzRWosXMRoNYRzkwwHBfwUp612pqJzUzSV9tNBqHJryWWy +PsL74rx44R1604N7qGSkfE1ci+JP7h1fLOw9M3Rb+1AmOigHomYRhRjNwhXcmp5u +spnubpOpJhYANFvQbard7yFmz2n1PcmtKOZussMN9F2w3CJ0pucDDEY+kpHVXiRa +j65STQi9rxoZVKjzCo4UGIrsURZCfrtZFQ5ga8JhzytY4rsgyF6Wl2gOiZ3E+nMs +34QDdL8ZMBU6in9lb/iVEvBuUdRFqRVtH+zoQRf1RQKBgQDnZps2u40/55XpeoYW +6fR5tmgGKN4bpcd7r5zRM+831n5v4MqBfJZEq/TeGSw2ddhQbzeezQg+CRzxuVy/ +MGNOKskGSZ5quamwqD3DDw8hIA6KvVpfBIEKfz4O3lbzP/3UsP3CM+c8FS2b7tzm +Mfggt1caVAj2dBd8cKyXS3bZRQKBgQDI5d4N2tAopvaRyzFXT4rhZPL1drOKCO0L +QMN8CRK1seke0W4j+pMqnT6uJd+mTGQH7aAUMFcbHvX1Pn8M5SudyljcleH8taxt +F8gw1tyH3+tnJqXiQOGFlEL6fX2V3ETThVPyVXQ2sIm17Q961tL+gSQPjYXPKTfU +IG37/9FnvwKBgBWzV6cAW7S8gSCOLvkDI7wuUP8S4hFxsI124Jv15N81rFHNoPAX +wPfbsHELp0vMLWcNpwerbrRyolZA7eO4I/f2pzeBu+uCUdmRTYl3ZhHTMcntDAaR +I5DacfVvAHR7cdB6cLG/sFXAHrDa67hiw0Q+LVr4uoZySKmQ336owxKJAoGBAMdZ +kicdYkF0rGevwZ5qB93xVkXNLAtlIBNyiIikWDSD/lfeafS5yR8YOgKFApD6bKiR +W6+s6EK5Tke1ZE1fexBwog0BjeY+QINgff44t0z9HZKV/zWsPB1ZKb12mRAEKyfZ +vZtSwKckNwKX4ix6z5RMgYQNYyJWPFf6dikBiMHxAoGBALEOli/ZehBqx5Bd7bHm +HKgZBuBmEDn0wdqB9bGXDdY84bjfNJ8crhiO+zFGzHRvwa+eO2dp0iffIFqXVG15 +/DjMPsMlaX2rmmHE0iYpTo3jbDm4TrGf8uhNFJBW2f7UMAvEK30NXi4aajzIadhD +LxmTaLeSxjQDE6BXgPlf2dr4 +-----END PRIVATE KEY-----`; + +const testApnsServerCert = `-----BEGIN CERTIFICATE----- +MIIDaDCCAlCgAwIBAgIUafG6emKuR1YWUNOTWjvy32lTx7YwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaYXBpLnNhbmRib3gucHVzaC5hcHBsZS5jb20wHhcNMjYw +NTAxMDIzMjM2WhcNMzYwNDI4MDIzMjM2WjAlMSMwIQYDVQQDDBphcGkuc2FuZGJv +eC5wdXNoLmFwcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALWX8MMbFP/8xrbUQLs43iVv70g6scd3jfuu/kMGEy3tlenOZ9m5fIlZ39EjeWOI +Jwl5fBNh3MuzJpFoxFSdvM6hojn/0tF3CajTTtj9RLVnFKNdbcv6+Xf5C2IBS3oY +MFmjj2MH8tIKC7eVdhw33o6OmQXaPysFHxKft5NQwiSIIUqfxXryExGEO4dUkdZI +awxx+rw/uNlnxLTfWxS9DDwloC8SGO7cZvFuFAfxGeJxHhXEj5tne/UlOdY1cJIK +VGZ8MAzQAWta/ySoleuFq3hGyml5FJEOMQ11QVL5W9baz+jJpT2FeXf8b71KvHkA +04J0vL6lNB2SrdsKWDtr3XsCAwEAAaOBjzCBjDAdBgNVHQ4EFgQUcS8iUpQu0qs4 +MHxfmbd6WjvplH4wHwYDVR0jBBgwFoAUcS8iUpQu0qs4MHxfmbd6WjvplH4wDwYD +VR0TAQH/BAUwAwEB/zA5BgNVHREEMjAwghphcGkuc2FuZGJveC5wdXNoLmFwcGxl +LmNvbYISYXBpLnB1c2guYXBwbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAVP+Qg +lAjpy9jINCeVkt4x/tdZvenag7tCD03ATQ/jrbndAkoHnJt7if1PXmH4+R/iW59X +yEv7o+2cTJa1g1QQgHMdiEBhGSGzNCQl8VhvZ6eZ6eeZuVLHZUPoZhV9+eax1sB/ +346JgSF6z2IIjr7H26jumZKuAqQsZwvQBOS20zZk+gewpHd4Xy3KxhLMz5Qtl7Df +ILty9ZCz2RlAy1H3bzxFEAVQt/SQ4cjmdI1U0svR3iHhpX9qT6DTZYvisjjpUBgN +0nu1jQgAYFHA2hQmgChmPJUYhkxjXtgemTYyiurXsi3VK/dQ9yrOBkk1MOwuOYZs +W8tBzWn/ZhBpWD88 +-----END CERTIFICATE-----`; + +type CapturedApnsRequest = { + headers: http2.IncomingHttpHeaders; + body: string; +}; + +type DestroyableConnection = { + destroy: () => void; +}; + function createDirectApnsSendFixture(params: { nodeId: string; environment: "sandbox" | "production"; @@ -72,6 +137,109 @@ function createRelayApnsSendFixture(params: { }; } +function listen(server: HttpServer | http2.Http2SecureServer): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("server address unavailable")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: HttpServer | http2.Http2SecureServer): Promise { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function startFakeApnsServer(): Promise<{ + port: number; + requests: CapturedApnsRequest[]; + stop: () => Promise; +}> { + const requests: CapturedApnsRequest[] = []; + const server = http2.createSecureServer({ + key: testApnsServerKeyPem, + cert: testApnsServerCert, + allowHTTP1: false, + }); + server.on("stream", (stream: http2.ServerHttp2Stream, headers) => { + let body = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + body += typeof chunk === "string" ? chunk : String(chunk); + }); + stream.on("end", () => { + requests.push({ headers, body }); + stream.respond({ ":status": 200, "apns-id": "proxied-apns-id" }); + stream.end(); + }); + }); + const port = await listen(server); + return { + port, + requests, + stop: async () => await closeServer(server), + }; +} + +async function startConnectProxy(upstreamPort: number): Promise<{ + proxyUrl: string; + connectTargets: string[]; + stop: () => Promise; +}> { + const connectTargets: string[] = []; + const sockets = new Set(); + const server = createServer((_req, res) => { + res.writeHead(502); + res.end("CONNECT required"); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + server.on("connect", (req, clientSocket, head) => { + connectTargets.push(req.url ?? ""); + const upstreamSocket = net.connect(upstreamPort, "127.0.0.1", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + upstreamSocket.write(head); + } + clientSocket.pipe(upstreamSocket); + upstreamSocket.pipe(clientSocket); + }); + sockets.add(clientSocket); + sockets.add(upstreamSocket); + clientSocket.on("close", () => sockets.delete(clientSocket)); + upstreamSocket.on("close", () => sockets.delete(upstreamSocket)); + clientSocket.on("error", () => upstreamSocket.destroy()); + upstreamSocket.on("error", () => clientSocket.destroy()); + }); + const port = await listen(server); + return { + proxyUrl: `http://127.0.0.1:${port}`, + connectTargets, + stop: async () => { + for (const socket of sockets) { + socket.destroy(); + } + await closeServer(server); + }, + }; +} + afterEach(async () => { vi.unstubAllGlobals(); }); @@ -116,6 +284,60 @@ describe("push APNs send semantics", () => { expect(result.transport).toBe("direct"); }); + it("routes direct APNs HTTP/2 requests through the active managed proxy", async () => { + const apnsServer = await startFakeApnsServer(); + const proxy = await startConnectProxy(apnsServer.port); + let proxyHandle: ProxyHandle | null = null; + const previousTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + try { + proxyHandle = await startProxy({ enabled: true, proxyUrl: proxy.proxyUrl }); + const { registration, auth } = createDirectApnsSendFixture({ + nodeId: "ios-node-proxied-alert", + environment: "sandbox", + sendResult: { + status: 200, + apnsId: "unused", + body: "", + }, + }); + + const result = await sendApnsAlert({ + registration, + nodeId: "ios-node-proxied-alert", + title: "Wake", + body: "Ping", + auth, + timeoutMs: 2_500, + }); + + expect(result).toMatchObject({ + ok: true, + status: 200, + apnsId: "proxied-apns-id", + transport: "direct", + }); + expect(proxy.connectTargets).toEqual(["api.sandbox.push.apple.com:443"]); + expect(apnsServer.requests).toHaveLength(1); + const request = apnsServer.requests[0]; + expect(request?.headers[":method"]).toBe("POST"); + expect(request?.headers[":path"]).toBe("/3/device/abcd1234abcd1234abcd1234abcd1234"); + expect(request?.headers["apns-topic"]).toBe("ai.openclaw.ios"); + expect(request?.headers["apns-push-type"]).toBe("alert"); + expect(request?.body).toContain('"nodeId":"ios-node-proxied-alert"'); + } finally { + if (previousTlsRejectUnauthorized === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = previousTlsRejectUnauthorized; + } + await stopProxy(proxyHandle); + await proxy.stop(); + await apnsServer.stop(); + } + }); + it("sends background wake pushes with silent payload semantics", async () => { const { send, registration, auth } = createDirectApnsSendFixture({ nodeId: "ios-node-wake", diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 5c4508ae859..63adadf0656 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -1,6 +1,5 @@ import { createHash, createPrivateKey, sign as signJwt } from "node:crypto"; import fs from "node:fs/promises"; -import http2 from "node:http2"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { @@ -10,6 +9,7 @@ import { import type { DeviceIdentity } from "./device-identity.js"; import { formatErrorMessage } from "./errors.js"; import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; +import { APNS_HTTP2_CANCEL_CODE, connectApnsHttp2Session } from "./push-apns-http2.js"; import { type ApnsRelayConfig, type ApnsRelayPushResponse, @@ -658,8 +658,12 @@ async function sendApnsRequest(params: { const body = JSON.stringify(params.payload); const requestPath = `/3/device/${params.token}`; + const client = await connectApnsHttp2Session({ + authority, + timeoutMs: params.timeoutMs, + }); + return await new Promise((resolve, reject) => { - const client = http2.connect(authority); let settled = false; const fail = (err: unknown) => { if (settled) { @@ -698,7 +702,7 @@ async function sendApnsRequest(params: { req.setEncoding("utf8"); req.setTimeout(params.timeoutMs, () => { - req.close(http2.constants.NGHTTP2_CANCEL); + req.close(APNS_HTTP2_CANCEL_CODE); fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`)); }); req.on("response", (headers) => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 6e8c853f839..b4ac1ca9d04 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -309,6 +309,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain( 'add_profile_suite native-live-src-gateway-core "minimum stable full"', ); + expect(workflow).toContain('add_profile_suite native-live-src-infra "stable full"'); expect(workflow).toContain('add_profile_suite live-gateway-docker "minimum stable full"'); expect(workflow).toContain('add_profile_suite live-gateway-anthropic-docker "stable full"'); expect(workflow).toContain('add_profile_suite live-gateway-advisory-docker "full"'); @@ -346,6 +347,10 @@ describe("package artifact reuse", () => { ); expect(workflow).toContain("suite_id: native-live-src-gateway-core"); expect(workflow).toContain("suite_id: native-live-src-gateway-backends"); + expect(workflow).toContain("suite_id: native-live-src-infra"); + expect(workflow).toContain( + "command: OPENCLAW_LIVE_APNS_REACHABILITY=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-infra", + ); expect(workflow).toContain("suite_id: native-live-src-gateway-profiles-anthropic-smoke"); expect(workflow).toContain("suite_id: native-live-src-gateway-profiles-anthropic-opus"); expect(workflow).toContain("suite_id: native-live-src-gateway-profiles-anthropic-sonnet-haiku"); diff --git a/test/scripts/run-additional-boundary-checks.test.ts b/test/scripts/run-additional-boundary-checks.test.ts index b6c986be781..1a5e9f099a7 100644 --- a/test/scripts/run-additional-boundary-checks.test.ts +++ b/test/scripts/run-additional-boundary-checks.test.ts @@ -57,6 +57,14 @@ describe("run-additional-boundary-checks", () => { expect(() => parseShardSpec("5/4")).toThrow("Invalid shard spec"); }); + it("keeps the raw HTTP/2 import guard in source boundary checks", () => { + expect(BOUNDARY_CHECKS).toContainEqual({ + label: "lint:tmp:no-raw-http2-imports", + command: "pnpm", + args: ["run", "lint:tmp:no-raw-http2-imports"], + }); + }); + it("buffers grouped output and reports aggregate failures", async () => { const buffer = createOutputBuffer(); const failures = await runChecks( diff --git a/test/scripts/test-live-shard.test.ts b/test/scripts/test-live-shard.test.ts index e3fcaeb445d..98fb620edde 100644 --- a/test/scripts/test-live-shard.test.ts +++ b/test/scripts/test-live-shard.test.ts @@ -74,6 +74,9 @@ describe("scripts/test-live-shard", () => { expect(selectLiveShardFiles("native-live-src-gateway-core", allFiles)).not.toEqual( expect.arrayContaining(["src/gateway/gateway-cli-backend.live.test.ts"]), ); + expect(selectLiveShardFiles("native-live-src-infra", allFiles)).toEqual( + expect.arrayContaining(["src/infra/push-apns-http2.live.test.ts"]), + ); expect(selectLiveShardFiles("native-live-test", allFiles)).toEqual( expect.arrayContaining([ "test/image-generation.infer-cli.live.test.ts",