mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(push): harden relay invalidation and url handling
This commit is contained in:
@@ -47,7 +47,18 @@ struct PushBuildConfig {
|
|||||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
return URL(string: trimmed)
|
guard let components = URLComponents(string: trimmed),
|
||||||
|
components.scheme?.lowercased() == "https",
|
||||||
|
let host = components.host,
|
||||||
|
!host.isEmpty,
|
||||||
|
components.user == nil,
|
||||||
|
components.password == nil,
|
||||||
|
components.query == nil,
|
||||||
|
components.fragment == nil
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readEnum<T: RawRepresentable>(
|
private static func readEnum<T: RawRepresentable>(
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ actor PushRegistrationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
|
private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
|
||||||
guard let expiresAtMs else { return false }
|
guard let expiresAtMs else { return true }
|
||||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
||||||
return expiresAtMs <= nowMs + 60_000
|
return expiresAtMs <= nowMs + 60_000
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
ok: true,
|
ok: true,
|
||||||
params: rawParams,
|
params: rawParams,
|
||||||
})),
|
})),
|
||||||
clearApnsRegistration: vi.fn(),
|
clearApnsRegistrationIfCurrent: vi.fn(),
|
||||||
loadApnsRegistration: vi.fn(),
|
loadApnsRegistration: vi.fn(),
|
||||||
resolveApnsAuthConfigFromEnv: vi.fn(),
|
resolveApnsAuthConfigFromEnv: vi.fn(),
|
||||||
resolveApnsRelayConfigFromEnv: vi.fn(),
|
resolveApnsRelayConfigFromEnv: vi.fn(),
|
||||||
@@ -33,7 +33,7 @@ vi.mock("../node-invoke-sanitize.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../infra/push-apns.js", () => ({
|
vi.mock("../../infra/push-apns.js", () => ({
|
||||||
clearApnsRegistration: mocks.clearApnsRegistration,
|
clearApnsRegistrationIfCurrent: mocks.clearApnsRegistrationIfCurrent,
|
||||||
loadApnsRegistration: mocks.loadApnsRegistration,
|
loadApnsRegistration: mocks.loadApnsRegistration,
|
||||||
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
|
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
|
||||||
resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
|
resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
|
||||||
@@ -197,7 +197,7 @@ describe("node.invoke APNs wake path", () => {
|
|||||||
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
|
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
|
||||||
);
|
);
|
||||||
mocks.loadApnsRegistration.mockClear();
|
mocks.loadApnsRegistration.mockClear();
|
||||||
mocks.clearApnsRegistration.mockClear();
|
mocks.clearApnsRegistrationIfCurrent.mockClear();
|
||||||
mocks.resolveApnsAuthConfigFromEnv.mockClear();
|
mocks.resolveApnsAuthConfigFromEnv.mockClear();
|
||||||
mocks.resolveApnsRelayConfigFromEnv.mockClear();
|
mocks.resolveApnsRelayConfigFromEnv.mockClear();
|
||||||
mocks.sendApnsBackgroundWake.mockClear();
|
mocks.sendApnsBackgroundWake.mockClear();
|
||||||
@@ -311,7 +311,17 @@ describe("node.invoke APNs wake path", () => {
|
|||||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||||
expect(call?.[0]).toBe(false);
|
expect(call?.[0]).toBe(false);
|
||||||
expect(call?.[2]?.message).toBe("node not connected");
|
expect(call?.[2]?.message).toBe("node not connected");
|
||||||
expect(mocks.clearApnsRegistration).toHaveBeenCalledWith("ios-node-stale");
|
expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
|
||||||
|
nodeId: "ios-node-stale",
|
||||||
|
registration: {
|
||||||
|
nodeId: "ios-node-stale",
|
||||||
|
transport: "direct",
|
||||||
|
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
updatedAtMs: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clear relay registrations from wake failures", async () => {
|
it("does not clear relay registrations from wake failures", async () => {
|
||||||
@@ -380,7 +390,7 @@ describe("node.invoke APNs wake path", () => {
|
|||||||
transport: "relay",
|
transport: "relay",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mocks.clearApnsRegistration).not.toHaveBeenCalled();
|
expect(mocks.clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces one retry wake when the first wake still fails to reconnect", async () => {
|
it("forces one retry wake when the first wake still fails to reconnect", async () => {
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
verifyNodeToken,
|
verifyNodeToken,
|
||||||
} from "../../infra/node-pairing.js";
|
} from "../../infra/node-pairing.js";
|
||||||
import {
|
import {
|
||||||
clearApnsRegistration,
|
clearApnsRegistrationIfCurrent,
|
||||||
loadApnsRegistration,
|
loadApnsRegistration,
|
||||||
resolveApnsAuthConfigFromEnv,
|
|
||||||
resolveApnsRelayConfigFromEnv,
|
|
||||||
sendApnsAlert,
|
sendApnsAlert,
|
||||||
sendApnsBackgroundWake,
|
sendApnsBackgroundWake,
|
||||||
shouldClearStoredApnsRegistration,
|
shouldClearStoredApnsRegistration,
|
||||||
|
resolveApnsAuthConfigFromEnv,
|
||||||
|
resolveApnsRelayConfigFromEnv,
|
||||||
} from "../../infra/push-apns.js";
|
} from "../../infra/push-apns.js";
|
||||||
import {
|
import {
|
||||||
buildCanvasScopedHostUrl,
|
buildCanvasScopedHostUrl,
|
||||||
@@ -95,22 +95,20 @@ type PendingNodeAction = {
|
|||||||
|
|
||||||
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
|
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
|
||||||
|
|
||||||
async function resolveNodePushConfig(
|
async function resolveDirectNodePushConfig() {
|
||||||
registration: NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
|
|
||||||
) {
|
|
||||||
if (registration.transport === "relay") {
|
|
||||||
const relay = resolveApnsRelayConfigFromEnv(process.env);
|
|
||||||
return relay.ok
|
|
||||||
? { ok: true as const, relayConfig: relay.value }
|
|
||||||
: { ok: false as const, error: relay.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||||
return auth.ok
|
return auth.ok
|
||||||
? { ok: true as const, auth: auth.value }
|
? { ok: true as const, auth: auth.value }
|
||||||
: { ok: false as const, error: auth.error };
|
: { ok: false as const, error: auth.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRelayNodePushConfig() {
|
||||||
|
const relay = resolveApnsRelayConfigFromEnv(process.env);
|
||||||
|
return relay.ok
|
||||||
|
? { ok: true as const, relayConfig: relay.value }
|
||||||
|
: { ok: false as const, error: relay.error };
|
||||||
|
}
|
||||||
|
|
||||||
async function clearStaleApnsRegistrationIfNeeded(
|
async function clearStaleApnsRegistrationIfNeeded(
|
||||||
registration: NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
|
registration: NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
@@ -124,7 +122,10 @@ async function clearStaleApnsRegistrationIfNeeded(
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await clearApnsRegistration(nodeId);
|
await clearApnsRegistrationIfCurrent({
|
||||||
|
nodeId,
|
||||||
|
registration,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||||
@@ -273,24 +274,41 @@ export async function maybeWakeNodeWithApns(
|
|||||||
return withDuration({ available: false, throttled: false, path: "no-registration" });
|
return withDuration({ available: false, throttled: false, path: "no-registration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = await resolveNodePushConfig(registration);
|
state.lastWakeAtMs = Date.now();
|
||||||
if (!resolved.ok) {
|
let wakeResult;
|
||||||
return withDuration({
|
if (registration.transport === "relay") {
|
||||||
available: false,
|
const relay = resolveRelayNodePushConfig();
|
||||||
throttled: false,
|
if (!relay.ok) {
|
||||||
path: "no-auth",
|
return withDuration({
|
||||||
apnsReason: resolved.error,
|
available: false,
|
||||||
|
throttled: false,
|
||||||
|
path: "no-auth",
|
||||||
|
apnsReason: relay.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wakeResult = await sendApnsBackgroundWake({
|
||||||
|
registration,
|
||||||
|
nodeId,
|
||||||
|
wakeReason: opts?.wakeReason ?? "node.invoke",
|
||||||
|
relayConfig: relay.relayConfig,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const auth = await resolveDirectNodePushConfig();
|
||||||
|
if (!auth.ok) {
|
||||||
|
return withDuration({
|
||||||
|
available: false,
|
||||||
|
throttled: false,
|
||||||
|
path: "no-auth",
|
||||||
|
apnsReason: auth.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wakeResult = await sendApnsBackgroundWake({
|
||||||
|
registration,
|
||||||
|
nodeId,
|
||||||
|
wakeReason: opts?.wakeReason ?? "node.invoke",
|
||||||
|
auth: auth.auth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
state.lastWakeAtMs = Date.now();
|
|
||||||
const wakeResult = await sendApnsBackgroundWake({
|
|
||||||
registration,
|
|
||||||
nodeId,
|
|
||||||
wakeReason: opts?.wakeReason ?? "node.invoke",
|
|
||||||
auth: "auth" in resolved ? resolved.auth : undefined,
|
|
||||||
relayConfig: "relayConfig" in resolved ? resolved.relayConfig : undefined,
|
|
||||||
});
|
|
||||||
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, wakeResult);
|
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, wakeResult);
|
||||||
if (!wakeResult.ok) {
|
if (!wakeResult.ok) {
|
||||||
return withDuration({
|
return withDuration({
|
||||||
@@ -353,25 +371,43 @@ export async function maybeSendNodeWakeNudge(nodeId: string): Promise<NodeWakeNu
|
|||||||
if (!registration) {
|
if (!registration) {
|
||||||
return withDuration({ sent: false, throttled: false, reason: "no-registration" });
|
return withDuration({ sent: false, throttled: false, reason: "no-registration" });
|
||||||
}
|
}
|
||||||
const resolved = await resolveNodePushConfig(registration);
|
|
||||||
if (!resolved.ok) {
|
|
||||||
return withDuration({
|
|
||||||
sent: false,
|
|
||||||
throttled: false,
|
|
||||||
reason: "no-auth",
|
|
||||||
apnsReason: resolved.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendApnsAlert({
|
let result;
|
||||||
registration,
|
if (registration.transport === "relay") {
|
||||||
nodeId,
|
const relay = resolveRelayNodePushConfig();
|
||||||
title: "OpenClaw needs a quick reopen",
|
if (!relay.ok) {
|
||||||
body: "Tap to reopen OpenClaw and restore the node connection.",
|
return withDuration({
|
||||||
auth: "auth" in resolved ? resolved.auth : undefined,
|
sent: false,
|
||||||
relayConfig: "relayConfig" in resolved ? resolved.relayConfig : undefined,
|
throttled: false,
|
||||||
});
|
reason: "no-auth",
|
||||||
|
apnsReason: relay.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result = await sendApnsAlert({
|
||||||
|
registration,
|
||||||
|
nodeId,
|
||||||
|
title: "OpenClaw needs a quick reopen",
|
||||||
|
body: "Tap to reopen OpenClaw and restore the node connection.",
|
||||||
|
relayConfig: relay.relayConfig,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const auth = await resolveDirectNodePushConfig();
|
||||||
|
if (!auth.ok) {
|
||||||
|
return withDuration({
|
||||||
|
sent: false,
|
||||||
|
throttled: false,
|
||||||
|
reason: "no-auth",
|
||||||
|
apnsReason: auth.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result = await sendApnsAlert({
|
||||||
|
registration,
|
||||||
|
nodeId,
|
||||||
|
title: "OpenClaw needs a quick reopen",
|
||||||
|
body: "Tap to reopen OpenClaw and restore the node connection.",
|
||||||
|
auth: auth.auth,
|
||||||
|
});
|
||||||
|
}
|
||||||
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, result);
|
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, result);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return withDuration({
|
return withDuration({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ErrorCodes } from "../protocol/index.js";
|
|||||||
import { pushHandlers } from "./push.js";
|
import { pushHandlers } from "./push.js";
|
||||||
|
|
||||||
vi.mock("../../infra/push-apns.js", () => ({
|
vi.mock("../../infra/push-apns.js", () => ({
|
||||||
clearApnsRegistration: vi.fn(),
|
clearApnsRegistrationIfCurrent: vi.fn(),
|
||||||
loadApnsRegistration: vi.fn(),
|
loadApnsRegistration: vi.fn(),
|
||||||
normalizeApnsEnvironment: vi.fn(),
|
normalizeApnsEnvironment: vi.fn(),
|
||||||
resolveApnsAuthConfigFromEnv: vi.fn(),
|
resolveApnsAuthConfigFromEnv: vi.fn(),
|
||||||
@@ -13,7 +13,7 @@ vi.mock("../../infra/push-apns.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clearApnsRegistration,
|
clearApnsRegistrationIfCurrent,
|
||||||
loadApnsRegistration,
|
loadApnsRegistration,
|
||||||
normalizeApnsEnvironment,
|
normalizeApnsEnvironment,
|
||||||
resolveApnsAuthConfigFromEnv,
|
resolveApnsAuthConfigFromEnv,
|
||||||
@@ -57,7 +57,7 @@ describe("push.test handler", () => {
|
|||||||
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
|
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
|
||||||
vi.mocked(resolveApnsRelayConfigFromEnv).mockClear();
|
vi.mocked(resolveApnsRelayConfigFromEnv).mockClear();
|
||||||
vi.mocked(sendApnsAlert).mockClear();
|
vi.mocked(sendApnsAlert).mockClear();
|
||||||
vi.mocked(clearApnsRegistration).mockClear();
|
vi.mocked(clearApnsRegistrationIfCurrent).mockClear();
|
||||||
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
|
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,17 @@ describe("push.test handler", () => {
|
|||||||
});
|
});
|
||||||
await invoke();
|
await invoke();
|
||||||
|
|
||||||
expect(clearApnsRegistration).toHaveBeenCalledWith("ios-node-1");
|
expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
registration: {
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
transport: "direct",
|
||||||
|
token: "abcd",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
updatedAtMs: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clear relay registrations after invalidation-shaped failures", async () => {
|
it("does not clear relay registrations after invalidation-shaped failures", async () => {
|
||||||
@@ -260,7 +270,7 @@ describe("push.test handler", () => {
|
|||||||
},
|
},
|
||||||
overrideEnvironment: null,
|
overrideEnvironment: null,
|
||||||
});
|
});
|
||||||
expect(clearApnsRegistration).not.toHaveBeenCalled();
|
expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clear direct registrations when push.test overrides the environment", async () => {
|
it("does not clear direct registrations when push.test overrides the environment", async () => {
|
||||||
@@ -320,6 +330,6 @@ describe("push.test handler", () => {
|
|||||||
},
|
},
|
||||||
overrideEnvironment: "production",
|
overrideEnvironment: "production",
|
||||||
});
|
});
|
||||||
expect(clearApnsRegistration).not.toHaveBeenCalled();
|
expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
clearApnsRegistration,
|
clearApnsRegistrationIfCurrent,
|
||||||
loadApnsRegistration,
|
loadApnsRegistration,
|
||||||
normalizeApnsEnvironment,
|
normalizeApnsEnvironment,
|
||||||
resolveApnsAuthConfigFromEnv,
|
resolveApnsAuthConfigFromEnv,
|
||||||
@@ -97,7 +97,10 @@ export const pushHandlers: GatewayRequestHandlers = {
|
|||||||
overrideEnvironment,
|
overrideEnvironment,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
await clearApnsRegistration(nodeId);
|
await clearApnsRegistrationIfCurrent({
|
||||||
|
nodeId,
|
||||||
|
registration,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function writeTextAtomic(
|
|||||||
await fs.mkdir(path.dirname(filePath), mkdirOptions);
|
await fs.mkdir(path.dirname(filePath), mkdirOptions);
|
||||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(tmp, payload, "utf8");
|
await fs.writeFile(tmp, payload, { encoding: "utf8", mode });
|
||||||
try {
|
try {
|
||||||
await fs.chmod(tmp, mode);
|
await fs.chmod(tmp, mode);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export function resolveApnsRelayConfigFromEnv(
|
|||||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||||
throw new Error("unsupported protocol");
|
throw new Error("unsupported protocol");
|
||||||
}
|
}
|
||||||
|
if (!parsed.hostname) {
|
||||||
|
throw new Error("host required");
|
||||||
|
}
|
||||||
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
|
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
|
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
|
||||||
@@ -93,6 +96,12 @@ export function resolveApnsRelayConfigFromEnv(
|
|||||||
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
|
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
|
||||||
throw new Error("http relay URLs are limited to loopback hosts");
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -119,6 +128,7 @@ async function sendApnsRelayRequest(params: {
|
|||||||
}): Promise<ApnsRelayPushResponse> {
|
}): Promise<ApnsRelayPushResponse> {
|
||||||
const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
|
const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${params.relayConfig.authToken}`,
|
authorization: `Bearer ${params.relayConfig.authToken}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
@@ -131,6 +141,14 @@ async function sendApnsRelayRequest(params: {
|
|||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
|
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;
|
let json: unknown = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
clearApnsRegistration,
|
clearApnsRegistration,
|
||||||
|
clearApnsRegistrationIfCurrent,
|
||||||
loadApnsRegistration,
|
loadApnsRegistration,
|
||||||
normalizeApnsEnvironment,
|
normalizeApnsEnvironment,
|
||||||
registerApnsRegistration,
|
registerApnsRegistration,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
shouldClearStoredApnsRegistration,
|
shouldClearStoredApnsRegistration,
|
||||||
shouldInvalidateApnsRegistration,
|
shouldInvalidateApnsRegistration,
|
||||||
} from "./push-apns.js";
|
} from "./push-apns.js";
|
||||||
|
import { sendApnsRelayPush } from "./push-apns.relay.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
||||||
@@ -29,6 +31,7 @@ async function makeTempDir(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
const dir = tempDirs.pop();
|
const dir = tempDirs.pop();
|
||||||
if (dir) {
|
if (dir) {
|
||||||
@@ -170,6 +173,41 @@ describe("push APNs registration store", () => {
|
|||||||
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
|
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
|
||||||
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
|
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("only clears a registration when the stored entry still matches", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const baseDir = await makeTempDir();
|
||||||
|
vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
|
||||||
|
const stale = await registerApnsToken({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
|
||||||
|
const fresh = await registerApnsToken({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
clearApnsRegistrationIfCurrent({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
registration: stale,
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("push APNs env config", () => {
|
describe("push APNs env config", () => {
|
||||||
@@ -265,6 +303,26 @@ describe("push APNs env config", () => {
|
|||||||
}
|
}
|
||||||
expect(resolved.error).toContain("loopback hosts");
|
expect(resolved.error).toContain("loopback hosts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects APNs relay URLs with query, fragment, or userinfo components", () => {
|
||||||
|
const withQuery = resolveApnsRelayConfigFromEnv({
|
||||||
|
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1",
|
||||||
|
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
expect(withQuery.ok).toBe(false);
|
||||||
|
if (!withQuery.ok) {
|
||||||
|
expect(withQuery.error).toContain("query and fragment are not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const withUserinfo = resolveApnsRelayConfigFromEnv({
|
||||||
|
OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path",
|
||||||
|
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
expect(withUserinfo.ok).toBe(false);
|
||||||
|
if (!withUserinfo.ok) {
|
||||||
|
expect(withUserinfo.error).toContain("userinfo is not allowed");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("push APNs send semantics", () => {
|
describe("push APNs send semantics", () => {
|
||||||
@@ -451,6 +509,36 @@ describe("push APNs send semantics", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not follow relay redirects", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 302,
|
||||||
|
json: vi.fn().mockRejectedValue(new Error("no body")),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||||
|
|
||||||
|
const result = await sendApnsRelayPush({
|
||||||
|
relayConfig: {
|
||||||
|
baseUrl: "https://relay.example.com",
|
||||||
|
authToken: "relay-secret",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
},
|
||||||
|
relayHandle: "relay-handle-123",
|
||||||
|
payload: { aps: { "content-available": 1 } },
|
||||||
|
pushType: "background",
|
||||||
|
priority: "5",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
status: 302,
|
||||||
|
reason: "RelayRedirectNotAllowed",
|
||||||
|
environment: "production",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("flags invalid device responses for registration invalidation", () => {
|
it("flags invalid device responses for registration invalidation", () => {
|
||||||
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
|
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
|
||||||
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
|
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
|
||||||
|
|||||||
@@ -353,7 +353,11 @@ async function persistRegistrationsState(
|
|||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const filePath = resolveApnsRegistrationPath(baseDir);
|
const filePath = resolveApnsRegistrationPath(baseDir);
|
||||||
await writeJsonAtomic(filePath, state);
|
await writeJsonAtomic(filePath, state, {
|
||||||
|
mode: 0o600,
|
||||||
|
ensureDirMode: 0o700,
|
||||||
|
trailingNewline: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
|
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
|
||||||
@@ -475,6 +479,51 @@ export async function clearApnsRegistration(nodeId: string, baseDir?: string): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boolean {
|
||||||
|
if (
|
||||||
|
a.nodeId !== b.nodeId ||
|
||||||
|
a.transport !== b.transport ||
|
||||||
|
a.topic !== b.topic ||
|
||||||
|
a.environment !== b.environment ||
|
||||||
|
a.updatedAtMs !== b.updatedAtMs
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a.transport === "direct" && b.transport === "direct") {
|
||||||
|
return a.token === b.token;
|
||||||
|
}
|
||||||
|
if (a.transport === "relay" && b.transport === "relay") {
|
||||||
|
return (
|
||||||
|
a.relayHandle === b.relayHandle &&
|
||||||
|
a.installationId === b.installationId &&
|
||||||
|
a.distribution === b.distribution &&
|
||||||
|
a.tokenDebugSuffix === b.tokenDebugSuffix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearApnsRegistrationIfCurrent(params: {
|
||||||
|
nodeId: string;
|
||||||
|
registration: ApnsRegistration;
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const normalizedNodeId = normalizeNodeId(params.nodeId);
|
||||||
|
if (!normalizedNodeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadRegistrationsState(params.baseDir);
|
||||||
|
const current = state.registrationsByNodeId[normalizedNodeId];
|
||||||
|
if (!current || !isSameApnsRegistration(current, params.registration)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
delete state.registrationsByNodeId[normalizedNodeId];
|
||||||
|
await persistRegistrationsState(state, params.baseDir);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldInvalidateApnsRegistration(result: {
|
export function shouldInvalidateApnsRegistration(result: {
|
||||||
status: number;
|
status: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -806,17 +855,54 @@ function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendApnsAlert(params: {
|
type ApnsAlertCommonParams = {
|
||||||
auth?: ApnsAuthConfig;
|
|
||||||
relayConfig?: ApnsRelayConfig;
|
|
||||||
registration: ApnsRegistration;
|
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectApnsAlertParams = ApnsAlertCommonParams & {
|
||||||
|
registration: DirectApnsRegistration;
|
||||||
|
auth: ApnsAuthConfig;
|
||||||
requestSender?: ApnsRequestSender;
|
requestSender?: ApnsRequestSender;
|
||||||
|
relayConfig?: never;
|
||||||
|
relayRequestSender?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RelayApnsAlertParams = ApnsAlertCommonParams & {
|
||||||
|
registration: RelayApnsRegistration;
|
||||||
|
relayConfig: ApnsRelayConfig;
|
||||||
relayRequestSender?: ApnsRelayRequestSender;
|
relayRequestSender?: ApnsRelayRequestSender;
|
||||||
}): Promise<ApnsPushAlertResult> {
|
auth?: never;
|
||||||
|
requestSender?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApnsBackgroundWakeCommonParams = {
|
||||||
|
nodeId: string;
|
||||||
|
wakeReason?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||||
|
registration: DirectApnsRegistration;
|
||||||
|
auth: ApnsAuthConfig;
|
||||||
|
requestSender?: ApnsRequestSender;
|
||||||
|
relayConfig?: never;
|
||||||
|
relayRequestSender?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||||
|
registration: RelayApnsRegistration;
|
||||||
|
relayConfig: ApnsRelayConfig;
|
||||||
|
relayRequestSender?: ApnsRelayRequestSender;
|
||||||
|
auth?: never;
|
||||||
|
requestSender?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendApnsAlert(
|
||||||
|
params: DirectApnsAlertParams | RelayApnsAlertParams,
|
||||||
|
): Promise<ApnsPushAlertResult> {
|
||||||
const payload = createAlertPayload({
|
const payload = createAlertPayload({
|
||||||
nodeId: params.nodeId,
|
nodeId: params.nodeId,
|
||||||
title: params.title,
|
title: params.title,
|
||||||
@@ -824,69 +910,54 @@ export async function sendApnsAlert(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (params.registration.transport === "relay") {
|
if (params.registration.transport === "relay") {
|
||||||
if (!params.relayConfig) {
|
const relayParams = params as RelayApnsAlertParams;
|
||||||
throw new Error("APNs relay config required");
|
|
||||||
}
|
|
||||||
return await sendRelayApnsPush({
|
return await sendRelayApnsPush({
|
||||||
relayConfig: params.relayConfig,
|
relayConfig: relayParams.relayConfig,
|
||||||
registration: params.registration,
|
registration: relayParams.registration,
|
||||||
payload,
|
payload,
|
||||||
pushType: "alert",
|
pushType: "alert",
|
||||||
priority: "10",
|
priority: "10",
|
||||||
requestSender: params.relayRequestSender,
|
requestSender: relayParams.relayRequestSender,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!params.auth) {
|
const directParams = params as DirectApnsAlertParams;
|
||||||
throw new Error("APNs auth required");
|
|
||||||
}
|
|
||||||
return await sendDirectApnsPush({
|
return await sendDirectApnsPush({
|
||||||
auth: params.auth,
|
auth: directParams.auth,
|
||||||
registration: params.registration,
|
registration: directParams.registration,
|
||||||
payload,
|
payload,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: directParams.timeoutMs,
|
||||||
requestSender: params.requestSender,
|
requestSender: directParams.requestSender,
|
||||||
pushType: "alert",
|
pushType: "alert",
|
||||||
priority: "10",
|
priority: "10",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendApnsBackgroundWake(params: {
|
export async function sendApnsBackgroundWake(
|
||||||
auth?: ApnsAuthConfig;
|
params: DirectApnsBackgroundWakeParams | RelayApnsBackgroundWakeParams,
|
||||||
relayConfig?: ApnsRelayConfig;
|
): Promise<ApnsPushWakeResult> {
|
||||||
registration: ApnsRegistration;
|
|
||||||
nodeId: string;
|
|
||||||
wakeReason?: string;
|
|
||||||
timeoutMs?: number;
|
|
||||||
requestSender?: ApnsRequestSender;
|
|
||||||
relayRequestSender?: ApnsRelayRequestSender;
|
|
||||||
}): Promise<ApnsPushWakeResult> {
|
|
||||||
const payload = createBackgroundPayload({
|
const payload = createBackgroundPayload({
|
||||||
nodeId: params.nodeId,
|
nodeId: params.nodeId,
|
||||||
wakeReason: params.wakeReason,
|
wakeReason: params.wakeReason,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.registration.transport === "relay") {
|
if (params.registration.transport === "relay") {
|
||||||
if (!params.relayConfig) {
|
const relayParams = params as RelayApnsBackgroundWakeParams;
|
||||||
throw new Error("APNs relay config required");
|
|
||||||
}
|
|
||||||
return await sendRelayApnsPush({
|
return await sendRelayApnsPush({
|
||||||
relayConfig: params.relayConfig,
|
relayConfig: relayParams.relayConfig,
|
||||||
registration: params.registration,
|
registration: relayParams.registration,
|
||||||
payload,
|
payload,
|
||||||
pushType: "background",
|
pushType: "background",
|
||||||
priority: "5",
|
priority: "5",
|
||||||
requestSender: params.relayRequestSender,
|
requestSender: relayParams.relayRequestSender,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!params.auth) {
|
const directParams = params as DirectApnsBackgroundWakeParams;
|
||||||
throw new Error("APNs auth required");
|
|
||||||
}
|
|
||||||
return await sendDirectApnsPush({
|
return await sendDirectApnsPush({
|
||||||
auth: params.auth,
|
auth: directParams.auth,
|
||||||
registration: params.registration,
|
registration: directParams.registration,
|
||||||
payload,
|
payload,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: directParams.timeoutMs,
|
||||||
requestSender: params.requestSender,
|
requestSender: directParams.requestSender,
|
||||||
pushType: "background",
|
pushType: "background",
|
||||||
priority: "5",
|
priority: "5",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user