From 93abfc0b229e7e0e09340ce3326ef48522e50f13 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 12 Mar 2026 16:22:53 +0200 Subject: [PATCH] feat(push): configure ios relay through gateway config --- apps/ios/Sources/Model/NodeAppModel.swift | 21 ++++++- apps/ios/Sources/OpenClawApp.swift | 7 +++ docs/gateway/configuration-reference.md | 12 ++++ docs/gateway/configuration.md | 43 +++++++++++++ docs/platforms/ios.md | 47 ++++++++++++++ src/config/schema.help.ts | 10 +++ src/config/schema.hints.ts | 1 + src/config/schema.tags.ts | 1 + src/config/types.gateway.ts | 16 +++++ src/config/zod-schema.ts | 17 +++++ .../server-methods/nodes.invoke-wake.test.ts | 22 +++++++ src/gateway/server-methods/nodes.ts | 2 +- src/gateway/server-methods/push.test.ts | 32 ++++++++++ src/gateway/server-methods/push.ts | 3 +- src/infra/push-apns.relay.ts | 26 +++++--- src/infra/push-apns.test.ts | 62 +++++++++++++++++++ 16 files changed, 312 insertions(+), 10 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 7b7eb5419eb..ee0dfba4a8e 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1183,7 +1183,15 @@ final class NodeAppModel { _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) } - return await self.notificationAuthorizationStatus() + let updatedStatus = await self.notificationAuthorizationStatus() + if Self.isNotificationAuthorizationAllowed(updatedStatus) { + // Refresh APNs registration immediately after the first permission grant so the + // gateway can receive a push registration without requiring an app relaunch. + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } + return updatedStatus } private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { @@ -1198,6 +1206,17 @@ final class NodeAppModel { } } + private static func isNotificationAuthorizationAllowed( + _ status: NotificationAuthorizationStatus + ) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + true + case .denied, .notDetermined: + false + } + } + private func runNotificationCall( timeoutSeconds: Double, operation: @escaping @Sendable () async throws -> T diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index c94b1209f8d..ae980b0216a 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge { let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false if !granted { return false } let updatedStatus = await self.notificationAuthorizationStatus(center: center) + if self.isAuthorizationStatusAllowed(updatedStatus) { + // Refresh APNs registration immediately after the first permission grant so the + // gateway can receive a push registration without requiring an app relaunch. + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } return self.isAuthorizationStatusAllowed(updatedStatus) case .denied: return false diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1e48f69d6f8..0fb4fb56971 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2445,6 +2445,14 @@ See [Plugins](/tools/plugin). // Remove tools from the default HTTP deny list allow: ["gateway"], }, + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 10000, + }, + }, + }, }, } ``` @@ -2470,6 +2478,10 @@ See [Plugins](/tools/plugin). - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext. - `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. +- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. +- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`. +- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above. +- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS. - Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ece612d101d..ecb14ed6bc5 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -225,6 +225,49 @@ When validation fails: + + Relay-backed push is configured in `openclaw.json`. + + Set this in gateway config: + + ```json5 + { + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + // Optional. Default: 10000 + timeoutMs: 10000, + }, + }, + }, + }, + } + ``` + + CLI equivalent: + + ```bash + openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com + ``` + + What this does: + + - Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay. + - Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token. + - Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay. + - Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment. + + Compatibility note: + + - `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides. + - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config. + + See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow. + + + ```json5 { diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 0a2eb5abae5..eccf1fc79b4 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -49,6 +49,53 @@ openclaw nodes status openclaw gateway call node.list --params "{}" ``` +## Relay-backed push for official builds + +Official distributed iOS builds use the external push relay instead of publishing the raw APNs +token to the gateway. + +Gateway-side requirement: + +```json5 +{ + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + }, + }, + }, + }, +} +``` + +How the flow works: + +- The iOS app registers with the relay using App Attest and the app receipt. +- The relay returns an opaque relay handle plus a registration-scoped send grant. +- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`. +- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges. +- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build. + +What the gateway does **not** need for this path: + +- No deployment-wide relay token. +- No direct APNs key for official/TestFlight relay-backed sends. + +Compatibility note: + +- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway. + +Local/manual builds remain on direct APNs. If you are testing those builds without the relay, the +gateway still needs direct APNs credentials: + +```bash +export OPENCLAW_APNS_TEAM_ID="TEAMID" +export OPENCLAW_APNS_KEY_ID="KEYID" +export OPENCLAW_APNS_PRIVATE_KEY_P8='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----' +``` + ## Discovery paths ### Bonjour (LAN) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bd93f711d91..80c19853efb 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -386,6 +386,16 @@ export const FIELD_HELP: Record = { "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", + "gateway.push": + "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.", + "gateway.push.apns": + "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.", + "gateway.push.apns.relay": + "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", + "gateway.push.apns.relay.baseUrl": + "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", + "gateway.push.apns.relay.timeoutMs": + "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.", "gateway.http.endpoints.chatCompletions.enabled": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.http.endpoints.chatCompletions.maxBodyBytes": diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 64d1acde778..9d56ff2566c 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -75,6 +75,7 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.controlUi.basePath": "/openclaw", "gateway.controlUi.root": "dist/control-ui", "gateway.controlUi.allowedOrigins": "https://control.example.com", + "gateway.push.apns.relay.baseUrl": "https://relay.example.com", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", }; diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 82bdc1d87cd..1abfb90d656 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -41,6 +41,7 @@ const TAG_PRIORITY: Record = { const TAG_OVERRIDES: Record = { "gateway.auth.token": ["security", "auth", "access", "network"], "gateway.auth.password": ["security", "auth", "access", "network"], + "gateway.push.apns.relay.baseUrl": ["network", "advanced"], "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [ "security", "access", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 58b061682a1..b5b96490c9a 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -345,6 +345,21 @@ export type GatewayHttpConfig = { securityHeaders?: GatewayHttpSecurityHeadersConfig; }; +export type GatewayPushApnsRelayConfig = { + /** Base HTTPS URL for the external iOS APNs relay service. */ + baseUrl?: string; + /** Timeout in milliseconds for relay send requests (default: 10000). */ + timeoutMs?: number; +}; + +export type GatewayPushApnsConfig = { + relay?: GatewayPushApnsRelayConfig; +}; + +export type GatewayPushConfig = { + apns?: GatewayPushApnsConfig; +}; + export type GatewayNodesConfig = { /** Browser routing policy for node-hosted browser proxies. */ browser?: { @@ -393,6 +408,7 @@ export type GatewayConfig = { reload?: GatewayReloadConfig; tls?: GatewayTlsConfig; http?: GatewayHttpConfig; + push?: GatewayPushConfig; nodes?: GatewayNodesConfig; /** * IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c35d1191b6f..1b24eebff4d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -789,6 +789,23 @@ export const OpenClawSchema = z }) .strict() .optional(), + push: z + .object({ + apns: z + .object({ + relay: z + .object({ + baseUrl: z.string().optional(), + timeoutMs: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), nodes: z .object({ browser: z diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 1b44e718f71..799207db3e4 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -325,6 +325,18 @@ describe("node.invoke APNs wake path", () => { }); it("does not clear relay registrations from wake failures", async () => { + mocks.loadConfig.mockReturnValue({ + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + }, + }, + }, + }); mocks.loadApnsRegistration.mockResolvedValue({ nodeId: "ios-node-relay", transport: "relay", @@ -368,6 +380,16 @@ describe("node.invoke APNs wake path", () => { const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(false); expect(call?.[2]?.message).toBe("node not connected"); + expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + }, + }, + }); expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ registration: { nodeId: "ios-node-relay", diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 315af3b1f63..7067f0df96e 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -103,7 +103,7 @@ async function resolveDirectNodePushConfig() { } function resolveRelayNodePushConfig() { - const relay = resolveApnsRelayConfigFromEnv(process.env); + const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway); return relay.ok ? { ok: true as const, relayConfig: relay.value } : { ok: false as const, error: relay.error }; diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index e38ff2a63e9..9997b336797 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../protocol/index.js"; import { pushHandlers } from "./push.js"; +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + vi.mock("../../infra/push-apns.js", () => ({ clearApnsRegistrationIfCurrent: vi.fn(), loadApnsRegistration: vi.fn(), @@ -52,6 +60,8 @@ function expectInvalidRequestResponse( describe("push.test handler", () => { beforeEach(() => { + mocks.loadConfig.mockClear(); + mocks.loadConfig.mockReturnValue({}); vi.mocked(loadApnsRegistration).mockClear(); vi.mocked(normalizeApnsEnvironment).mockClear(); vi.mocked(resolveApnsAuthConfigFromEnv).mockClear(); @@ -115,6 +125,18 @@ describe("push.test handler", () => { }); it("sends push test through relay registrations", async () => { + mocks.loadConfig.mockReturnValue({ + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + }, + }, + }, + }); vi.mocked(loadApnsRegistration).mockResolvedValue({ nodeId: "ios-node-1", transport: "relay", @@ -153,6 +175,16 @@ describe("push.test handler", () => { expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled(); expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1); + expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + }, + }, + }); expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = respond.mock.calls[0] as RespondCall | undefined; expect(call?.[0]).toBe(true); diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index 57d4270496d..7cdf3125965 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -1,3 +1,4 @@ +import { loadConfig } from "../../config/config.js"; import { clearApnsRegistrationIfCurrent, loadApnsRegistration, @@ -74,7 +75,7 @@ export const pushHandlers: GatewayRequestHandlers = { }); })() : await (async () => { - const relay = resolveApnsRelayConfigFromEnv(process.env); + const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway); if (!relay.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error)); return null; diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index f993e9127dc..af851a1483b 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -1,4 +1,5 @@ import { URL } from "node:url"; +import type { GatewayConfig } from "../config/types.gateway.js"; export type ApnsRelayPushType = "alert" | "background"; @@ -36,9 +37,10 @@ function normalizeNonEmptyString(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function normalizeTimeoutMs(value: string | undefined): number { - const raw = value?.trim(); - if (!raw) { +function normalizeTimeoutMs(value: string | number | undefined): number { + const raw = + typeof value === "number" ? value : typeof value === "string" ? value.trim() : undefined; + if (raw === undefined || raw === "") { return DEFAULT_APNS_RELAY_TIMEOUT_MS; } const parsed = Number(raw); @@ -69,12 +71,20 @@ function parseReason(value: unknown): string | undefined { export function resolveApnsRelayConfigFromEnv( env: NodeJS.ProcessEnv = process.env, + gatewayConfig?: GatewayConfig, ): ApnsRelayConfigResolution { - const baseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL); + const configuredRelay = gatewayConfig?.push?.apns?.relay; + const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL); + const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl); + const baseUrl = envBaseUrl ?? configBaseUrl; + const baseUrlSource = envBaseUrl + ? "OPENCLAW_APNS_RELAY_BASE_URL" + : "gateway.push.apns.relay.baseUrl"; if (!baseUrl) { return { ok: false, - error: "APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL", + error: + "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL", }; } @@ -104,14 +114,16 @@ export function resolveApnsRelayConfigFromEnv( ok: true, value: { baseUrl: parsed.toString().replace(/\/+$/, ""), - timeoutMs: normalizeTimeoutMs(env.OPENCLAW_APNS_RELAY_TIMEOUT_MS), + timeoutMs: normalizeTimeoutMs( + env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs, + ), }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, - error: `invalid OPENCLAW_APNS_RELAY_BASE_URL (${baseUrl}): ${message}`, + error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`, }; } } diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index aa2e9dc7b72..e85e134d01f 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -263,6 +263,52 @@ describe("push APNs env config", () => { }); }); + it("resolves APNs relay config from gateway config", () => { + const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com/base/", + timeoutMs: 2500, + }, + }, + }, + }); + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "https://relay.example.com/base", + timeoutMs: 2500, + }, + }); + }); + + it("lets relay env overrides win over gateway config", () => { + const resolved = resolveApnsRelayConfigFromEnv( + { + OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com", + OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000", + } as NodeJS.ProcessEnv, + { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 2500, + }, + }, + }, + }, + ); + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "https://relay-override.example.com", + timeoutMs: 3000, + }, + }); + }); + it("rejects insecure APNs relay http URLs by default", () => { const resolved = resolveApnsRelayConfigFromEnv({ OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", @@ -321,6 +367,22 @@ describe("push APNs env config", () => { expect(withUserinfo.error).toContain("userinfo is not allowed"); } }); + + it("reports the config key name for invalid gateway relay URLs", () => { + const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com/path?debug=1", + }, + }, + }, + }); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl"); + } + }); }); describe("push APNs send semantics", () => {