Skip to content

Commit 4ac3d5d

Browse files
committed
fix(ios): bind relay sends to registration origin
1 parent f5461a0 commit 4ac3d5d

10 files changed

Lines changed: 185 additions & 61 deletions

File tree

apps/ios/Sources/Push/PushRegistrationManager.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ private struct RelayGatewayPushRegistrationPayload: Encodable {
1717
var topic: String
1818
var environment: String
1919
var distribution: String
20+
var relayOrigin: String
2021
var tokenDebugSuffix: String?
2122
}
2223

@@ -107,6 +108,7 @@ actor PushRegistrationManager {
107108
topic: topic,
108109
environment: self.buildConfig.apnsEnvironment.rawValue,
109110
distribution: self.buildConfig.distribution.rawValue,
111+
relayOrigin: relayOrigin,
110112
tokenDebugSuffix: stored.tokenDebugSuffix))
111113
}
112114

@@ -138,6 +140,7 @@ actor PushRegistrationManager {
138140
topic: topic,
139141
environment: self.buildConfig.apnsEnvironment.rawValue,
140142
distribution: self.buildConfig.distribution.rawValue,
143+
relayOrigin: relayOrigin,
141144
tokenDebugSuffix: registrationState.tokenDebugSuffix))
142145
}
143146

src/gateway/exec-approval-ios-push.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,30 @@ async function resolveDeliveryPlan(params: {
157157
}
158158
}
159159

160-
let relayConfig: ApnsRelayConfig | undefined;
160+
const relayConfigByNodeId = new Map<string, ApnsRelayConfig>();
161161
if (needsRelay) {
162-
const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway);
163-
if (relay.ok) {
164-
relayConfig = relay.value;
165-
} else {
166-
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
162+
for (const target of targets) {
163+
if (target.registration.transport !== "relay") {
164+
continue;
165+
}
166+
const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway, {
167+
registrationRelayOrigin: target.registration.relayOrigin,
168+
});
169+
if (relay.ok) {
170+
relayConfigByNodeId.set(target.nodeId, relay.value);
171+
} else {
172+
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
173+
}
167174
}
168175
}
176+
const relayConfig = relayConfigByNodeId.values().next().value;
169177

170178
return {
171179
targets: targets.filter((target) =>
172-
target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig),
180+
target.registration.transport === "direct"
181+
? Boolean(directAuth)
182+
: relayConfigByNodeId.has(target.nodeId) &&
183+
relayConfigByNodeId.get(target.nodeId)?.baseUrl === relayConfig?.baseUrl,
173184
),
174185
directAuth,
175186
relayConfig,

src/gateway/server-methods/nodes.invoke-wake.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -729,13 +729,17 @@ describe("node.invoke APNs wake path", () => {
729729
apnsReason: "Unregistered",
730730
apnsStatus: 410,
731731
});
732-
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
733-
push: {
734-
apns: {
735-
relay: DEFAULT_RELAY_CONFIG,
732+
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(
733+
process.env,
734+
{
735+
push: {
736+
apns: {
737+
relay: DEFAULT_RELAY_CONFIG,
738+
},
736739
},
737740
},
738-
});
741+
{ registrationRelayOrigin: undefined },
742+
);
739743
expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
740744
registration,
741745
result: {

src/gateway/server-methods/nodes.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,16 @@ async function resolveDirectNodePushConfig() {
199199
: { ok: false as const, error: auth.error };
200200
}
201201

202-
function resolveRelayNodePushConfig(cfg: OpenClawConfig) {
203-
const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway);
202+
function resolveRelayNodePushConfig(
203+
cfg: OpenClawConfig,
204+
registration: Extract<
205+
NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
206+
{ transport: "relay" }
207+
>,
208+
) {
209+
const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway, {
210+
registrationRelayOrigin: registration.relayOrigin,
211+
});
204212
return relay.ok
205213
? { ok: true as const, relayConfig: relay.value }
206214
: { ok: false as const, error: relay.error };
@@ -493,7 +501,7 @@ export async function maybeWakeNodeWithApns(
493501

494502
let wakeResult;
495503
if (registration.transport === "relay") {
496-
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig());
504+
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration);
497505
if (!relay.ok) {
498506
return withDuration({
499507
available: false,
@@ -595,7 +603,7 @@ export async function maybeSendNodeWakeNudge(
595603
try {
596604
let result;
597605
if (registration.transport === "relay") {
598-
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig());
606+
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration);
599607
if (!relay.ok) {
600608
return withDuration({
601609
sent: false,

src/gateway/server-methods/push.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,20 @@ describe("push.test handler", () => {
209209

210210
expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled();
211211
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1);
212-
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
213-
push: {
214-
apns: {
215-
relay: {
216-
baseUrl: "https://relay.example.com",
217-
timeoutMs: 1000,
212+
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(
213+
process.env,
214+
{
215+
push: {
216+
apns: {
217+
relay: {
218+
baseUrl: "https://relay.example.com",
219+
timeoutMs: 1000,
220+
},
218221
},
219222
},
220223
},
221-
});
224+
{ registrationRelayOrigin: undefined },
225+
);
222226
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
223227
const call = firstRespondCall(respond);
224228
expect(call?.[0]).toBe(true);

src/gateway/server-methods/push.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const pushHandlers: GatewayRequestHandlers = {
8585
const relay = resolveApnsRelayConfigFromEnv(
8686
process.env,
8787
context.getRuntimeConfig().gateway,
88+
{ registrationRelayOrigin: registration.relayOrigin },
8889
);
8990
if (!relay.ok) {
9091
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error));

src/gateway/server-node-events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ export const handleNodeEvent = async (
828828
topic,
829829
environment,
830830
distribution: obj.distribution,
831+
relayOrigin: obj.relayOrigin,
831832
tokenDebugSuffix: obj.tokenDebugSuffix,
832833
});
833834
} else {

src/infra/push-apns.relay.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,38 @@ function firstMockCall<T extends unknown[]>(mock: { mock: { calls: T[] } }): T |
6464

6565
describe("push-apns.relay", () => {
6666
describe("resolveApnsRelayConfigFromEnv", () => {
67-
it("defaults to the hosted relay when no relay base URL is configured", () => {
68-
expectRelayConfig(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv), {
69-
baseUrl: DEFAULT_APNS_RELAY_BASE_URL,
70-
timeoutMs: 10_000,
71-
});
67+
it("defaults to the hosted relay when the registration was minted by the hosted relay", () => {
68+
expectRelayConfig(
69+
resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, undefined, {
70+
registrationRelayOrigin: `${DEFAULT_APNS_RELAY_BASE_URL}/`,
71+
}),
72+
{
73+
baseUrl: DEFAULT_APNS_RELAY_BASE_URL,
74+
timeoutMs: 10_000,
75+
},
76+
);
77+
});
78+
79+
it("fails closed when relay registration origin is unknown and no relay URL is configured", () => {
80+
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv);
81+
82+
expect(resolved.ok).toBe(false);
83+
if (!resolved.ok) {
84+
expect(resolved.error).toContain("relay registrations without the hosted relay origin");
85+
}
86+
});
87+
88+
it("rejects config that does not match the registration relay origin", () => {
89+
const resolved = resolveApnsRelayConfigFromEnv(
90+
{} as NodeJS.ProcessEnv,
91+
{ push: { apns: { relay: { baseUrl: DEFAULT_APNS_RELAY_BASE_URL } } } },
92+
{ registrationRelayOrigin: "https://relay.example.com" },
93+
);
94+
95+
expect(resolved.ok).toBe(false);
96+
if (!resolved.ok) {
97+
expect(resolved.error).toContain("origin mismatch");
98+
}
7299
});
73100

74101
it("lets env overrides win and clamps tiny timeout values", () => {

src/infra/push-apns.relay.ts

Lines changed: 79 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ type ApnsRelayConfigResolution =
2323
| { ok: true; value: ApnsRelayConfig }
2424
| { ok: false; error: string };
2525

26+
type ApnsRelayConfigResolutionOptions = {
27+
registrationRelayOrigin?: string;
28+
};
29+
2630
export type ApnsRelayPushResponse = {
2731
ok: boolean;
2832
status: number;
@@ -94,6 +98,38 @@ function parseReason(value: unknown): string | undefined {
9498
return typeof value === "string" ? normalizeOptionalString(value) : undefined;
9599
}
96100

101+
export function normalizeApnsRelayBaseUrl(
102+
baseUrl: string,
103+
env: NodeJS.ProcessEnv = process.env,
104+
): { ok: true; value: string } | { ok: false; error: string } {
105+
try {
106+
const parsed = new URL(baseUrl);
107+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
108+
throw new Error("unsupported protocol");
109+
}
110+
if (!parsed.hostname) {
111+
throw new Error("host required");
112+
}
113+
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
114+
throw new Error(
115+
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
116+
);
117+
}
118+
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
119+
throw new Error("http relay URLs are limited to loopback hosts");
120+
}
121+
if (parsed.username || parsed.password) {
122+
throw new Error("userinfo is not allowed");
123+
}
124+
if (parsed.search || parsed.hash) {
125+
throw new Error("query and fragment are not allowed");
126+
}
127+
return { ok: true, value: parsed.toString().replace(/\/+$/, "") };
128+
} catch (err) {
129+
return { ok: false, error: formatErrorMessage(err) };
130+
}
131+
}
132+
97133
function buildRelayGatewaySignaturePayload(params: {
98134
gatewayDeviceId: string;
99135
signedAtMs: number;
@@ -110,55 +146,65 @@ function buildRelayGatewaySignaturePayload(params: {
110146
export function resolveApnsRelayConfigFromEnv(
111147
env: NodeJS.ProcessEnv = process.env,
112148
gatewayConfig?: GatewayConfig,
149+
options: ApnsRelayConfigResolutionOptions = {},
113150
): ApnsRelayConfigResolution {
114151
const configuredRelay = gatewayConfig?.push?.apns?.relay;
115152
const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
116153
const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl);
117-
const baseUrl = envBaseUrl ?? configBaseUrl ?? DEFAULT_APNS_RELAY_BASE_URL;
154+
const explicitBaseUrl = envBaseUrl ?? configBaseUrl;
155+
const normalizedRegistrationOrigin = options.registrationRelayOrigin
156+
? normalizeApnsRelayBaseUrl(options.registrationRelayOrigin, env)
157+
: undefined;
158+
if (normalizedRegistrationOrigin && !normalizedRegistrationOrigin.ok) {
159+
return {
160+
ok: false,
161+
error: `invalid relay registration origin (${options.registrationRelayOrigin}): ${normalizedRegistrationOrigin.error}`,
162+
};
163+
}
164+
165+
const baseUrl =
166+
explicitBaseUrl ??
167+
(normalizedRegistrationOrigin?.value === DEFAULT_APNS_RELAY_BASE_URL
168+
? DEFAULT_APNS_RELAY_BASE_URL
169+
: undefined);
118170
const baseUrlSource = envBaseUrl
119171
? "OPENCLAW_APNS_RELAY_BASE_URL"
120172
: configBaseUrl
121173
? "gateway.push.apns.relay.baseUrl"
122174
: "default APNs relay base URL";
175+
if (!baseUrl) {
176+
return {
177+
ok: false,
178+
error:
179+
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL for relay registrations without the hosted relay origin",
180+
};
181+
}
123182

124-
try {
125-
const parsed = new URL(baseUrl);
126-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
127-
throw new Error("unsupported protocol");
128-
}
129-
if (!parsed.hostname) {
130-
throw new Error("host required");
131-
}
132-
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
133-
throw new Error(
134-
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
135-
);
136-
}
137-
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
138-
throw new Error("http relay URLs are limited to loopback hosts");
139-
}
140-
if (parsed.username || parsed.password) {
141-
throw new Error("userinfo is not allowed");
142-
}
143-
if (parsed.search || parsed.hash) {
144-
throw new Error("query and fragment are not allowed");
145-
}
183+
const normalizedBaseUrl = normalizeApnsRelayBaseUrl(baseUrl, env);
184+
if (!normalizedBaseUrl.ok) {
146185
return {
147-
ok: true,
148-
value: {
149-
baseUrl: parsed.toString().replace(/\/+$/, ""),
150-
timeoutMs: normalizeTimeoutMs(
151-
env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
152-
),
153-
},
186+
ok: false,
187+
error: `invalid ${baseUrlSource} (${baseUrl}): ${normalizedBaseUrl.error}`,
154188
};
155-
} catch (err) {
156-
const message = formatErrorMessage(err);
189+
}
190+
if (
191+
normalizedRegistrationOrigin &&
192+
normalizedRegistrationOrigin.value !== normalizedBaseUrl.value
193+
) {
157194
return {
158195
ok: false,
159-
error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
196+
error: `APNs relay config origin mismatch: registration uses ${normalizedRegistrationOrigin.value} but ${baseUrlSource} is ${normalizedBaseUrl.value}`,
160197
};
161198
}
199+
return {
200+
ok: true,
201+
value: {
202+
baseUrl: normalizedBaseUrl.value,
203+
timeoutMs: normalizeTimeoutMs(
204+
env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
205+
),
206+
},
207+
};
162208
}
163209

164210
async function sendApnsRelayRequest(params: {

0 commit comments

Comments
 (0)