diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift index d784ab89f92..18bfd35e348 100644 --- a/apps/ios/Sources/Push/PushRegistrationManager.swift +++ b/apps/ios/Sources/Push/PushRegistrationManager.swift @@ -17,6 +17,7 @@ private struct RelayGatewayPushRegistrationPayload: Encodable { var topic: String var environment: String var distribution: String + var relayOrigin: String var tokenDebugSuffix: String? } @@ -107,6 +108,7 @@ actor PushRegistrationManager { topic: topic, environment: self.buildConfig.apnsEnvironment.rawValue, distribution: self.buildConfig.distribution.rawValue, + relayOrigin: relayOrigin, tokenDebugSuffix: stored.tokenDebugSuffix)) } @@ -138,6 +140,7 @@ actor PushRegistrationManager { topic: topic, environment: self.buildConfig.apnsEnvironment.rawValue, distribution: self.buildConfig.distribution.rawValue, + relayOrigin: relayOrigin, tokenDebugSuffix: registrationState.tokenDebugSuffix)) } diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index f0872b4f4b8..12ea05ae22f 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -157,19 +157,30 @@ async function resolveDeliveryPlan(params: { } } - let relayConfig: ApnsRelayConfig | undefined; + const relayConfigByNodeId = new Map(); if (needsRelay) { - const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway); - if (relay.ok) { - relayConfig = relay.value; - } else { - params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`); + for (const target of targets) { + if (target.registration.transport !== "relay") { + continue; + } + const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway, { + registrationRelayOrigin: target.registration.relayOrigin, + }); + if (relay.ok) { + relayConfigByNodeId.set(target.nodeId, relay.value); + } else { + params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`); + } } } + const relayConfig = relayConfigByNodeId.values().next().value; return { targets: targets.filter((target) => - target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig), + target.registration.transport === "direct" + ? Boolean(directAuth) + : relayConfigByNodeId.has(target.nodeId) && + relayConfigByNodeId.get(target.nodeId)?.baseUrl === relayConfig?.baseUrl, ), directAuth, relayConfig, diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index f6cbf9d0ee0..117352cc7e1 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -729,13 +729,17 @@ describe("node.invoke APNs wake path", () => { apnsReason: "Unregistered", apnsStatus: 410, }); - expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { - push: { - apns: { - relay: DEFAULT_RELAY_CONFIG, + expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith( + process.env, + { + push: { + apns: { + relay: DEFAULT_RELAY_CONFIG, + }, }, }, - }); + { registrationRelayOrigin: undefined }, + ); expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ registration, result: { diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 71f378e912a..0f4741bd141 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -199,8 +199,16 @@ async function resolveDirectNodePushConfig() { : { ok: false as const, error: auth.error }; } -function resolveRelayNodePushConfig(cfg: OpenClawConfig) { - const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway); +function resolveRelayNodePushConfig( + cfg: OpenClawConfig, + registration: Extract< + NonNullable>>, + { transport: "relay" } + >, +) { + const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway, { + registrationRelayOrigin: registration.relayOrigin, + }); return relay.ok ? { ok: true as const, relayConfig: relay.value } : { ok: false as const, error: relay.error }; @@ -493,7 +501,7 @@ export async function maybeWakeNodeWithApns( let wakeResult; if (registration.transport === "relay") { - const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig()); + const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration); if (!relay.ok) { return withDuration({ available: false, @@ -595,7 +603,7 @@ export async function maybeSendNodeWakeNudge( try { let result; if (registration.transport === "relay") { - const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig()); + const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration); if (!relay.ok) { return withDuration({ sent: false, diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 6ffaa8ad94e..031eb0e6f9a 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -209,16 +209,20 @@ 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(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith( + process.env, + { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, }, }, }, - }); + { registrationRelayOrigin: undefined }, + ); expect(sendApnsAlert).toHaveBeenCalledTimes(1); const call = firstRespondCall(respond); expect(call?.[0]).toBe(true); diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index a61ad466d46..bbd78d4a71c 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -85,6 +85,7 @@ export const pushHandlers: GatewayRequestHandlers = { const relay = resolveApnsRelayConfigFromEnv( process.env, context.getRuntimeConfig().gateway, + { registrationRelayOrigin: registration.relayOrigin }, ); if (!relay.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error)); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 45c33b0a38a..7c4aa3290d9 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -828,6 +828,7 @@ export const handleNodeEvent = async ( topic, environment, distribution: obj.distribution, + relayOrigin: obj.relayOrigin, tokenDebugSuffix: obj.tokenDebugSuffix, }); } else { diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts index 2a1a03cb79e..763b05d07e6 100644 --- a/src/infra/push-apns.relay.test.ts +++ b/src/infra/push-apns.relay.test.ts @@ -64,11 +64,38 @@ function firstMockCall(mock: { mock: { calls: T[] } }): T | describe("push-apns.relay", () => { describe("resolveApnsRelayConfigFromEnv", () => { - it("defaults to the hosted relay when no relay base URL is configured", () => { - expectRelayConfig(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv), { - baseUrl: DEFAULT_APNS_RELAY_BASE_URL, - timeoutMs: 10_000, - }); + it("defaults to the hosted relay when the registration was minted by the hosted relay", () => { + expectRelayConfig( + resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, undefined, { + registrationRelayOrigin: `${DEFAULT_APNS_RELAY_BASE_URL}/`, + }), + { + baseUrl: DEFAULT_APNS_RELAY_BASE_URL, + timeoutMs: 10_000, + }, + ); + }); + + it("fails closed when relay registration origin is unknown and no relay URL is configured", () => { + const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv); + + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toContain("relay registrations without the hosted relay origin"); + } + }); + + it("rejects config that does not match the registration relay origin", () => { + const resolved = resolveApnsRelayConfigFromEnv( + {} as NodeJS.ProcessEnv, + { push: { apns: { relay: { baseUrl: DEFAULT_APNS_RELAY_BASE_URL } } } }, + { registrationRelayOrigin: "https://relay.example.com" }, + ); + + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toContain("origin mismatch"); + } }); it("lets env overrides win and clamps tiny timeout values", () => { diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index b7623fd086d..359c4cbe3c0 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -23,6 +23,10 @@ type ApnsRelayConfigResolution = | { ok: true; value: ApnsRelayConfig } | { ok: false; error: string }; +type ApnsRelayConfigResolutionOptions = { + registrationRelayOrigin?: string; +}; + export type ApnsRelayPushResponse = { ok: boolean; status: number; @@ -94,33 +98,10 @@ function parseReason(value: unknown): string | undefined { return typeof value === "string" ? normalizeOptionalString(value) : undefined; } -function buildRelayGatewaySignaturePayload(params: { - gatewayDeviceId: string; - signedAtMs: number; - bodyJson: string; -}): string { - return [ - "openclaw-relay-send-v1", - params.gatewayDeviceId.trim(), - String(Math.trunc(params.signedAtMs)), - params.bodyJson, - ].join("\n"); -} - -export function resolveApnsRelayConfigFromEnv( +export function normalizeApnsRelayBaseUrl( + baseUrl: string, env: NodeJS.ProcessEnv = process.env, - gatewayConfig?: GatewayConfig, -): ApnsRelayConfigResolution { - const configuredRelay = gatewayConfig?.push?.apns?.relay; - const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL); - const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl); - const baseUrl = envBaseUrl ?? configBaseUrl ?? DEFAULT_APNS_RELAY_BASE_URL; - const baseUrlSource = envBaseUrl - ? "OPENCLAW_APNS_RELAY_BASE_URL" - : configBaseUrl - ? "gateway.push.apns.relay.baseUrl" - : "default APNs relay base URL"; - +): { ok: true; value: string } | { ok: false; error: string } { try { const parsed = new URL(baseUrl); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { @@ -143,22 +124,87 @@ export function resolveApnsRelayConfigFromEnv( if (parsed.search || parsed.hash) { throw new Error("query and fragment are not allowed"); } - return { - ok: true, - value: { - baseUrl: parsed.toString().replace(/\/+$/, ""), - timeoutMs: normalizeTimeoutMs( - env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs, - ), - }, - }; + return { ok: true, value: parsed.toString().replace(/\/+$/, "") }; } catch (err) { - const message = formatErrorMessage(err); + return { ok: false, error: formatErrorMessage(err) }; + } +} + +function buildRelayGatewaySignaturePayload(params: { + gatewayDeviceId: string; + signedAtMs: number; + bodyJson: string; +}): string { + return [ + "openclaw-relay-send-v1", + params.gatewayDeviceId.trim(), + String(Math.trunc(params.signedAtMs)), + params.bodyJson, + ].join("\n"); +} + +export function resolveApnsRelayConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, + gatewayConfig?: GatewayConfig, + options: ApnsRelayConfigResolutionOptions = {}, +): ApnsRelayConfigResolution { + const configuredRelay = gatewayConfig?.push?.apns?.relay; + const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL); + const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl); + const explicitBaseUrl = envBaseUrl ?? configBaseUrl; + const normalizedRegistrationOrigin = options.registrationRelayOrigin + ? normalizeApnsRelayBaseUrl(options.registrationRelayOrigin, env) + : undefined; + if (normalizedRegistrationOrigin && !normalizedRegistrationOrigin.ok) { return { ok: false, - error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`, + error: `invalid relay registration origin (${options.registrationRelayOrigin}): ${normalizedRegistrationOrigin.error}`, }; } + + const baseUrl = + explicitBaseUrl ?? + (normalizedRegistrationOrigin?.value === DEFAULT_APNS_RELAY_BASE_URL + ? DEFAULT_APNS_RELAY_BASE_URL + : undefined); + const baseUrlSource = envBaseUrl + ? "OPENCLAW_APNS_RELAY_BASE_URL" + : configBaseUrl + ? "gateway.push.apns.relay.baseUrl" + : "default APNs relay base URL"; + if (!baseUrl) { + return { + ok: false, + error: + "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL for relay registrations without the hosted relay origin", + }; + } + + const normalizedBaseUrl = normalizeApnsRelayBaseUrl(baseUrl, env); + if (!normalizedBaseUrl.ok) { + return { + ok: false, + error: `invalid ${baseUrlSource} (${baseUrl}): ${normalizedBaseUrl.error}`, + }; + } + if ( + normalizedRegistrationOrigin && + normalizedRegistrationOrigin.value !== normalizedBaseUrl.value + ) { + return { + ok: false, + error: `APNs relay config origin mismatch: registration uses ${normalizedRegistrationOrigin.value} but ${baseUrlSource} is ${normalizedBaseUrl.value}`, + }; + } + return { + ok: true, + value: { + baseUrl: normalizedBaseUrl.value, + timeoutMs: normalizeTimeoutMs( + env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs, + ), + }, + }; } async function sendApnsRelayRequest(params: { diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index d42bd675183..772f2ded47b 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -14,6 +14,7 @@ import { type ApnsRelayConfig, type ApnsRelayPushResponse, type ApnsRelayRequestSender, + normalizeApnsRelayBaseUrl, resolveApnsRelayConfigFromEnv, sendApnsRelayPush, } from "./push-apns.relay.js"; @@ -40,6 +41,7 @@ type RelayApnsRegistration = { environment: "production"; distribution: "official"; updatedAtMs: number; + relayOrigin?: string; tokenDebugSuffix?: string; }; @@ -109,6 +111,7 @@ type RegisterRelayApnsParams = { topic: string; environment?: unknown; distribution?: unknown; + relayOrigin?: unknown; tokenDebugSuffix?: unknown; baseDir?: string; }; @@ -263,6 +266,18 @@ function normalizeDistribution(value: unknown): "official" | null { return normalized === "official" ? "official" : null; } +function normalizeRelayOrigin(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return undefined; + } + const normalized = normalizeApnsRelayBaseUrl(trimmed, process.env); + return normalized.ok ? normalized.value : undefined; +} + function normalizeDirectRegistration( record: Partial & { nodeId?: unknown; token?: unknown }, ): DirectApnsRegistration | null { @@ -312,6 +327,7 @@ function normalizeRelayRegistration( const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : ""); const environment = normalizeApnsEnvironment(record.environment); const distribution = normalizeDistribution(record.distribution); + const relayOrigin = normalizeRelayOrigin(record.relayOrigin); const updatedAtMs = typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs) ? Math.trunc(record.updatedAtMs) @@ -337,6 +353,7 @@ function normalizeRelayRegistration( environment, distribution, updatedAtMs, + ...(relayOrigin ? { relayOrigin } : {}), tokenDebugSuffix: normalizeTokenDebugSuffix(record.tokenDebugSuffix), }; } @@ -433,6 +450,7 @@ export async function registerApnsRegistration( ); const environment = normalizeApnsEnvironment(params.environment); const distribution = normalizeDistribution(params.distribution); + const relayOrigin = normalizeRelayOrigin(params.relayOrigin); if (environment !== "production") { throw new Error("relay registrations must use production environment"); } @@ -449,6 +467,7 @@ export async function registerApnsRegistration( environment, distribution, updatedAtMs, + ...(relayOrigin ? { relayOrigin } : {}), tokenDebugSuffix: normalizeTokenDebugSuffix(params.tokenDebugSuffix), }; } else {