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 dab7c86a75.
- Required merge gates passed before the squash merge.

Prepared head SHA: dab7c86a75
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>
This commit is contained in:
Jesse Merhi
2026-05-04 21:04:17 +10:00
committed by GitHub
parent 5efbb3078a
commit d5b0083300
30 changed files with 2159 additions and 89 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -23,7 +23,7 @@ captured blobs, and purge local capture data.
```bash
openclaw proxy start [--host <host>] [--port <port>]
openclaw proxy run [--host <host>] [--port <port>] -- <cmd...>
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--timeout-ms <ms>]
openclaw proxy validate [--json] [--proxy-url <url>] [--allowed-url <url>] [--denied-url <url>] [--apns-reachable] [--apns-authority <url>] [--timeout-ms <ms>]
openclaw proxy coverage
openclaw proxy sessions [--limit <count>]
openclaw proxy query --preset <name> [--session <id>]
@@ -40,7 +40,10 @@ before changing config. By default it verifies that a public destination succeed
through the proxy and that the proxy cannot reach a temporary loopback canary.
Custom denied destinations are fail-closed: HTTP responses and ambiguous
transport failures both fail unless you can verify a deployment-specific denial
signal separately.
signal separately. Add `--apns-reachable` to also open an APNs HTTP/2 CONNECT
tunnel through the proxy and confirm sandbox APNs responds; the probe uses an
intentionally invalid provider token, so an APNs `403 InvalidProviderToken`
response is a successful reachability signal.
Options:
@@ -48,6 +51,8 @@ Options:
- `--proxy-url <url>`: validate this proxy URL instead of config or env.
- `--allowed-url <url>`: add a destination expected to succeed through the proxy. Repeat to check multiple destinations.
- `--denied-url <url>`: add a destination expected to be blocked by the proxy. Repeat to check multiple destinations.
- `--apns-reachable`: also verify sandbox APNs HTTP/2 is reachable through the proxy.
- `--apns-authority <url>`: APNs authority to probe with `--apns-reachable` (`https://api.sandbox.push.apple.com` by default; production is `https://api.push.apple.com`).
- `--timeout-ms <ms>`: per-request timeout in milliseconds.
See [Network Proxy](/security/network-proxy) for deployment guidance and denial

View File

@@ -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`

View File

@@ -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
}
]
}

View File

@@ -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",

View File

@@ -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();

View File

@@ -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",

View File

@@ -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":

View File

@@ -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: <unknown>
# 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(...)

View File

@@ -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(...)

View File

@@ -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) => `<heading>${value}</heading>`,
success: (value: string) => `<success>${value}</success>`,
error: (value: string) => `<error>${value}</error>`,
muted: (value: string) => `<muted>${value}</muted>`,
warn: (value: string) => `<warn>${value}</warn>`,
},
}));
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("<success>Proxy validation passed</success>");
expect(output).toContain("<heading>Checks</heading>");
expect(output).toContain("<success>✓</success>");
} 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",
);

View File

@@ -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);

View File

@@ -26,6 +26,8 @@ describe("proxy cli", () => {
"--proxy-url",
"--allowed-url",
"--denied-url",
"--apns-reachable",
"--apns-authority",
"--timeout-ms",
]);
});

View File

@@ -67,6 +67,8 @@ export function registerProxyCli(program: Command) {
collectOption,
)
.option("--denied-url <url>", "Destination expected to be blocked by the proxy", collectOption)
.option("--apns-reachable", "Also verify sandbox APNs HTTP/2 is reachable through the proxy")
.option("--apns-authority <url>", "APNs authority to probe with --apns-reachable")
.option("--timeout-ms <ms>", "Per-request timeout in milliseconds", parseOptionalNumber)
.action(
async (opts: {
@@ -74,6 +76,8 @@ export function registerProxyCli(program: Command) {
proxyUrl?: string;
allowedUrl?: string[];
deniedUrl?: string[];
apnsReachable?: boolean;
apnsAuthority?: string;
timeoutMs?: number;
}) => {
const runtime = await loadProxyCliRuntime();
@@ -82,6 +86,8 @@ export function registerProxyCli(program: Command) {
proxyUrl: opts.proxyUrl,
allowedUrls: opts.allowedUrl,
deniedUrls: opts.deniedUrl,
apnsReachability: opts.apnsReachable,
apnsAuthority: opts.apnsAuthority,
timeoutMs: opts.timeoutMs,
});
},

View File

@@ -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);
});
});

View File

@@ -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 "<invalid proxy URL>";
}
}
function resolveProxyHost(proxy: URL): string {
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
}
function resolveProxyPort(proxy: URL): number {
if (proxy.port) {
return Number(proxy.port);
}
return proxy.protocol === "https:" ? 443 : 80;
}
function resolveProxyAuthorization(proxy: URL): string | undefined {
if (!proxy.username && !proxy.password) {
return undefined;
}
const username = decodeURIComponent(proxy.username);
const password = decodeURIComponent(proxy.password);
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function formatTunnelFailure(proxyUrl: URL, err: unknown): Error {
return new Error(
`Proxy CONNECT failed via ${redactProxyUrl(proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"];
const authorization = resolveProxyAuthorization(proxy);
if (authorization) {
headers.push(`Proxy-Authorization: ${authorization}`);
}
socket.write([...headers, "", ""].join("\r\n"));
}
function assertConnectHeaderBytesWithinLimit(size: number): void {
if (size > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
throw new Error(
`Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`,
);
}
}
function readProxyConnectResponse(
responseBuffer: ConnectResponseBuffer,
chunk: ConnectResponseBuffer,
): ProxyConnectReadResult {
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<tls.TLSSocket> {
const proxy = new URL(params.proxyUrl.href);
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`);
}
return await new Promise<tls.TLSSocket>((resolve, reject) => {
new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start();
});
}

View File

@@ -0,0 +1,52 @@
export type ActiveManagedProxyUrl = Readonly<URL>;
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;
}

View File

@@ -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<string, unknown>)["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<string, unknown>)["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");

View File

@@ -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<string, unknown> & {
host: string;
tls: Record<string, unknown>;
@@ -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<object>();
export function _resetGlobalAgentBootstrapForTests(): void {
globalAgentBootstrapped = false;
nodeHttpStackSnapshot = null;
activeProxyRegistrations = [];
baseProxyEnvSnapshot = null;
patchedGlobalAgentHttpsAgents = new WeakSet<object>();
}
@@ -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<Proxy
}
const proxyUrl = resolveProxyUrl(config);
const previousActiveRegistration = findTopActiveProxyRegistration();
const activeProxyUrl = getActiveManagedProxyUrl();
if (activeProxyUrl) {
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl));
const handle: ProxyHandle = {
proxyUrl,
injectedProxyUrl: proxyUrl,
envSnapshot: baseProxyEnvSnapshot ?? captureProxyEnv(),
stop: async () => {
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,
});

View File

@@ -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",
},
]);
});
});

View File

@@ -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<ProxyValidationFetchCheckResult>;
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<ProxyValidationApnsCheckResult>;
export type ResolveProxyValidationConfigOptions = {
config?: ProxyConfig;
env?: NodeJS.ProcessEnv | Partial<Record<"OPENCLAW_PROXY_URL", string | undefined>>;
@@ -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<ProxyValidationApnsCheckResult> {
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<ProxyValidationCheck> {
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<ProxyValidationResult> {
@@ -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();
}

View File

@@ -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<void> {
await new Promise<void>((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<void>((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,
);
});

View File

@@ -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<string, Array<(...args: unknown[]) => void>>();
on(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
return this;
}
once(event: string, handler: (...args: unknown[]) => void): this {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
handler(...args);
};
return this.on(event, wrapped);
}
off(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(
event,
(this.handlers.get(event) ?? []).filter((candidate) => candidate !== handler),
);
return this;
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
reset(): void {
this.handlers.clear();
}
}
const fakeRequest = Object.assign(new FakeEmitter(), {
setEncoding: vi.fn(),
end: vi.fn(() => {
queueMicrotask(() => {
fakeRequest.emit("response", { ":status": 403 });
fakeRequest.emit("data", '{"reason":"InvalidProviderToken"}');
fakeRequest.emit("end");
});
}),
});
const fakeSession = Object.assign(new FakeEmitter(), {
closed: false,
destroyed: false,
close: vi.fn(() => {
fakeSession.closed = true;
}),
destroy: vi.fn(() => {
fakeSession.destroyed = true;
}),
request: vi.fn(() => fakeRequest),
});
const fakeTlsSocket = { encrypted: true };
return {
fakeRequest,
fakeSession,
fakeTlsSocket,
connectSpy: vi.fn(() => fakeSession),
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");
});
});

View File

@@ -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<string, string>;
};
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<http2.ClientHttp2Session> {
const apnsHost = new URL(params.authority).hostname;
const tlsSocket = await openHttpConnectTunnel({
proxyUrl: params.proxyUrl,
targetHost: apnsHost,
targetPort: 443,
timeoutMs: params.timeoutMs,
});
return http2.connect(params.authority, {
createConnection: () => tlsSocket,
});
}
export async function connectApnsHttp2Session(
params: ConnectApnsHttp2SessionParams,
): Promise<http2.ClientHttp2Session> {
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<ProbeApnsHttp2ReachabilityViaProxyResult> {
const authority = assertApnsAuthority(params.authority);
const session = await openProxiedApnsHttp2Session({
authority,
proxyUrl: new URL(params.proxyUrl),
timeoutMs: params.timeoutMs,
});
try {
return await new Promise<ProbeApnsHttp2ReachabilityViaProxyResult>((resolve, reject) => {
let settled = false;
let body = "";
let status: number | undefined;
let responseHeaders: Record<string, string> = {};
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();
}
}
}

View File

@@ -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<number> {
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<void> {
await new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function startFakeApnsServer(): Promise<{
port: number;
requests: CapturedApnsRequest[];
stop: () => Promise<void>;
}> {
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<void>;
}> {
const connectTargets: string[] = [];
const sockets = new Set<DestroyableConnection>();
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",

View File

@@ -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) => {

View File

@@ -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");

View File

@@ -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(

View File

@@ -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",