mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 13:50:23 +00:00
* feat(push): add ios apns relay gateway * fix(shared): avoid oslog string concatenation # Conflicts: # apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift * fix(push): harden relay validation and invalidation * fix(push): persist app attest state before relay registration * fix(push): harden relay invalidation and url handling * feat(push): use scoped relay send grants * feat(push): configure ios relay through gateway config * feat(push): bind relay registration to gateway identity * fix(push): tighten ios relay trust flow * fix(push): bound APNs registration fields (#43369) (thanks @ngutman)
255 lines
7.4 KiB
TypeScript
255 lines
7.4 KiB
TypeScript
import { URL } from "node:url";
|
|
import type { GatewayConfig } from "../config/types.gateway.js";
|
|
import {
|
|
loadOrCreateDeviceIdentity,
|
|
signDevicePayload,
|
|
type DeviceIdentity,
|
|
} from "./device-identity.js";
|
|
|
|
export type ApnsRelayPushType = "alert" | "background";
|
|
|
|
export type ApnsRelayConfig = {
|
|
baseUrl: string;
|
|
timeoutMs: number;
|
|
};
|
|
|
|
export type ApnsRelayConfigResolution =
|
|
| { ok: true; value: ApnsRelayConfig }
|
|
| { ok: false; error: string };
|
|
|
|
export type ApnsRelayPushResponse = {
|
|
ok: boolean;
|
|
status: number;
|
|
apnsId?: string;
|
|
reason?: string;
|
|
environment: "production";
|
|
tokenSuffix?: string;
|
|
};
|
|
|
|
export type ApnsRelayRequestSender = (params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
sendGrant: string;
|
|
relayHandle: string;
|
|
gatewayDeviceId: string;
|
|
signature: string;
|
|
signedAtMs: number;
|
|
bodyJson: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
}) => Promise<ApnsRelayPushResponse>;
|
|
|
|
const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000;
|
|
const GATEWAY_DEVICE_ID_HEADER = "x-openclaw-gateway-device-id";
|
|
const GATEWAY_SIGNATURE_HEADER = "x-openclaw-gateway-signature";
|
|
const GATEWAY_SIGNED_AT_HEADER = "x-openclaw-gateway-signed-at-ms";
|
|
|
|
function normalizeNonEmptyString(value: string | undefined): string | null {
|
|
const trimmed = value?.trim() ?? "";
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
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);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
|
|
}
|
|
return Math.max(1000, Math.trunc(parsed));
|
|
}
|
|
|
|
function readAllowHttp(value: string | undefined): boolean {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
}
|
|
|
|
function isLoopbackRelayHostname(hostname: string): boolean {
|
|
const normalized = hostname.trim().toLowerCase();
|
|
return (
|
|
normalized === "localhost" ||
|
|
normalized === "::1" ||
|
|
normalized === "[::1]" ||
|
|
/^127(?:\.\d{1,3}){3}$/.test(normalized)
|
|
);
|
|
}
|
|
|
|
function parseReason(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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(
|
|
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;
|
|
const baseUrlSource = envBaseUrl
|
|
? "OPENCLAW_APNS_RELAY_BASE_URL"
|
|
: "gateway.push.apns.relay.baseUrl";
|
|
if (!baseUrl) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
throw new Error("unsupported protocol");
|
|
}
|
|
if (!parsed.hostname) {
|
|
throw new Error("host required");
|
|
}
|
|
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
|
|
throw new Error(
|
|
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
|
|
);
|
|
}
|
|
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
|
|
throw new Error("http relay URLs are limited to loopback hosts");
|
|
}
|
|
if (parsed.username || parsed.password) {
|
|
throw new Error("userinfo is not allowed");
|
|
}
|
|
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,
|
|
),
|
|
},
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
ok: false,
|
|
error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function sendApnsRelayRequest(params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
sendGrant: string;
|
|
relayHandle: string;
|
|
gatewayDeviceId: string;
|
|
signature: string;
|
|
signedAtMs: number;
|
|
bodyJson: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
}): Promise<ApnsRelayPushResponse> {
|
|
const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
authorization: `Bearer ${params.sendGrant}`,
|
|
"content-type": "application/json",
|
|
[GATEWAY_DEVICE_ID_HEADER]: params.gatewayDeviceId,
|
|
[GATEWAY_SIGNATURE_HEADER]: params.signature,
|
|
[GATEWAY_SIGNED_AT_HEADER]: String(params.signedAtMs),
|
|
},
|
|
body: params.bodyJson,
|
|
signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
|
|
});
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
reason: "RelayRedirectNotAllowed",
|
|
environment: "production",
|
|
};
|
|
}
|
|
|
|
let json: unknown = null;
|
|
try {
|
|
json = (await response.json()) as unknown;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
const body =
|
|
json && typeof json === "object" && !Array.isArray(json)
|
|
? (json as Record<string, unknown>)
|
|
: {};
|
|
|
|
const status =
|
|
typeof body.status === "number" && Number.isFinite(body.status)
|
|
? Math.trunc(body.status)
|
|
: response.status;
|
|
return {
|
|
ok: typeof body.ok === "boolean" ? body.ok : response.ok && status >= 200 && status < 300,
|
|
status,
|
|
apnsId: parseReason(body.apnsId),
|
|
reason: parseReason(body.reason),
|
|
environment: "production",
|
|
tokenSuffix: parseReason(body.tokenSuffix),
|
|
};
|
|
}
|
|
|
|
export async function sendApnsRelayPush(params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
sendGrant: string;
|
|
relayHandle: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
|
requestSender?: ApnsRelayRequestSender;
|
|
}): Promise<ApnsRelayPushResponse> {
|
|
const sender = params.requestSender ?? sendApnsRelayRequest;
|
|
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
|
|
const signedAtMs = Date.now();
|
|
const bodyJson = JSON.stringify({
|
|
relayHandle: params.relayHandle,
|
|
pushType: params.pushType,
|
|
priority: Number(params.priority),
|
|
payload: params.payload,
|
|
});
|
|
const signature = signDevicePayload(
|
|
gatewayIdentity.privateKeyPem,
|
|
buildRelayGatewaySignaturePayload({
|
|
gatewayDeviceId: gatewayIdentity.deviceId,
|
|
signedAtMs,
|
|
bodyJson,
|
|
}),
|
|
);
|
|
return await sender({
|
|
relayConfig: params.relayConfig,
|
|
sendGrant: params.sendGrant,
|
|
relayHandle: params.relayHandle,
|
|
gatewayDeviceId: gatewayIdentity.deviceId,
|
|
signature,
|
|
signedAtMs,
|
|
bodyJson,
|
|
pushType: params.pushType,
|
|
priority: params.priority,
|
|
payload: params.payload,
|
|
});
|
|
}
|