* feat: make relay opt-in for device pairing * fix(relay): keep pairing state authoritative * fix(relay): refresh pairing state across transports * fix(onboard): honor explicit relay opt-out * fix(relay): surface transport startup failures * fix(pairing): gate offers for older daemons * test(cli): rely on real pairing transport coverage * fix(cli): report live relay without a pid file * fix(relay): synchronize config during daemon startup * fix(relay): preserve mutability for ignored overrides * fix(cli): use reachable daemon for pairing * fix(app): redirect invalid pair device routes * fix(pairing): support remote host device pairing Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals. * fix(pairing): validate pairing destinations Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state. * fix(pairing): render QR codes across platforms Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack. * fix(cli): pair from persisted relay state Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
/// <reference lib="dom" />
|
|
import { EventEmitter } from "node:events";
|
|
import { WebSocket } from "ws";
|
|
import type pino from "pino";
|
|
import {
|
|
createDaemonChannel,
|
|
type Transport as RelayTransport,
|
|
type KeyPair,
|
|
} from "@getpaseo/relay/e2ee";
|
|
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
|
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
|
import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js";
|
|
|
|
export interface RelayTransportOptions {
|
|
logger: pino.Logger;
|
|
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
|
|
relayEndpoint: string; // "host:port"
|
|
relayUseTls: boolean;
|
|
serverId: string;
|
|
daemonKeyPair?: KeyPair;
|
|
createWebSocket?: RelayWebSocketFactory;
|
|
}
|
|
|
|
export interface RelayTransportController {
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
export interface RelaySocketLike {
|
|
readyState: number;
|
|
bufferedAmount?: number;
|
|
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
|
|
close: (code?: number, reason?: string) => void;
|
|
terminate?: () => void;
|
|
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
|
|
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
|
|
}
|
|
|
|
interface RelayWebSocketLike extends RelaySocketLike {
|
|
terminate: () => void;
|
|
ping: () => void;
|
|
on: (
|
|
event: "open" | "message" | "close" | "error" | "pong",
|
|
listener: (...args: unknown[]) => void,
|
|
) => void;
|
|
}
|
|
|
|
type RelayWebSocketFactory = (url: string) => RelayWebSocketLike;
|
|
|
|
type ControlMessage =
|
|
| { type: "sync"; connectionIds: string[] }
|
|
| { type: "connected"; connectionId: string }
|
|
| { type: "disconnected"; connectionId: string }
|
|
| { type: "ping" }
|
|
| { type: "pong" };
|
|
|
|
const CONTROL_PING_INTERVAL_MS = 10_000;
|
|
const CONTROL_STALE_TIMEOUT_MS = 30_000;
|
|
const CONTROL_READY_TIMEOUT_MS = 8_000;
|
|
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
|
|
|
|
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
|
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
|
try {
|
|
let text: string;
|
|
if (typeof raw === "string") {
|
|
text = raw;
|
|
} else if (Buffer.isBuffer(raw)) {
|
|
text = raw.toString("utf8");
|
|
} else {
|
|
text = String(raw);
|
|
}
|
|
const parsed = JSON.parse(text);
|
|
if (!isRecord(parsed)) return null;
|
|
if (parsed.type === "ping") return { type: "ping" };
|
|
if (parsed.type === "pong") return { type: "pong" };
|
|
if (parsed.type === "sync" && Array.isArray(parsed.connectionIds)) {
|
|
const connectionIds = parsed.connectionIds.filter(
|
|
(id: unknown) => typeof id === "string" && id.trim().length > 0,
|
|
);
|
|
return { type: "sync", connectionIds };
|
|
}
|
|
if (
|
|
parsed.type === "connected" &&
|
|
typeof parsed.connectionId === "string" &&
|
|
parsed.connectionId.trim()
|
|
) {
|
|
return { type: "connected", connectionId: parsed.connectionId.trim() };
|
|
}
|
|
if (
|
|
parsed.type === "disconnected" &&
|
|
typeof parsed.connectionId === "string" &&
|
|
parsed.connectionId.trim()
|
|
) {
|
|
return { type: "disconnected", connectionId: parsed.connectionId.trim() };
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function startRelayTransport({
|
|
logger,
|
|
attachSocket,
|
|
relayEndpoint,
|
|
relayUseTls,
|
|
serverId,
|
|
daemonKeyPair,
|
|
createWebSocket = createDefaultRelayWebSocket,
|
|
}: RelayTransportOptions): RelayTransportController {
|
|
const relayLogger = logger.child({ module: "relay-transport" });
|
|
|
|
let stopped = false;
|
|
let controlWs: RelayWebSocketLike | null = null;
|
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let reconnectAttempt = 0;
|
|
const dataSockets = new Map<string, RelayWebSocketLike>(); // connectionId -> ws
|
|
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let controlLastSeenAt = 0;
|
|
let controlConnectionSeq = 0;
|
|
|
|
const stop = async (): Promise<void> => {
|
|
stopped = true;
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
if (controlKeepaliveInterval) {
|
|
clearInterval(controlKeepaliveInterval);
|
|
controlKeepaliveInterval = null;
|
|
}
|
|
if (controlReadyTimeout) {
|
|
clearTimeout(controlReadyTimeout);
|
|
controlReadyTimeout = null;
|
|
}
|
|
if (controlWs) {
|
|
try {
|
|
controlWs.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
controlWs = null;
|
|
}
|
|
for (const ws of dataSockets.values()) {
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
dataSockets.clear();
|
|
};
|
|
|
|
const connectControl = (): void => {
|
|
if (stopped) return;
|
|
|
|
const connectionId = ++controlConnectionSeq;
|
|
const url = buildRelayWebSocketUrl({
|
|
endpoint: relayEndpoint,
|
|
useTls: relayUseTls,
|
|
serverId,
|
|
role: "server",
|
|
});
|
|
const socket = createWebSocket(url);
|
|
controlWs = socket;
|
|
let controlConnected = false;
|
|
|
|
const markControlReady = () => {
|
|
if (controlWs !== socket) return;
|
|
if (controlConnected) return;
|
|
controlConnected = true;
|
|
reconnectAttempt = 0;
|
|
if (controlReadyTimeout) {
|
|
clearTimeout(controlReadyTimeout);
|
|
controlReadyTimeout = null;
|
|
}
|
|
relayLogger.info({ connectionId }, "relay_control_connected");
|
|
};
|
|
|
|
socket.on("open", () => {
|
|
if (controlWs !== socket) return;
|
|
|
|
controlLastSeenAt = Date.now();
|
|
if (controlKeepaliveInterval) {
|
|
clearInterval(controlKeepaliveInterval);
|
|
controlKeepaliveInterval = null;
|
|
}
|
|
if (controlReadyTimeout) {
|
|
clearTimeout(controlReadyTimeout);
|
|
controlReadyTimeout = null;
|
|
}
|
|
controlReadyTimeout = setTimeout(() => {
|
|
if (stopped) return;
|
|
if (controlWs !== socket) return;
|
|
if (controlConnected) return;
|
|
relayLogger.warn(
|
|
{ url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS },
|
|
"relay_control_ready_timeout_terminating",
|
|
);
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, CONTROL_READY_TIMEOUT_MS);
|
|
controlKeepaliveInterval = setInterval(() => {
|
|
if (stopped) return;
|
|
if (controlWs !== socket) return;
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
|
|
const now = Date.now();
|
|
const staleForMs = now - controlLastSeenAt;
|
|
// If the control socket is half-open or silently dropped, ws may never emit "close".
|
|
// Use a WebSocket protocol ping to detect staleness and force a reconnect.
|
|
// Cloudflare's runtime auto-responds to protocol pings at the edge without waking the
|
|
// hibernated relay Durable Object, so this keepalive does not incur DO CPU billing.
|
|
if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
|
|
relayLogger.warn(
|
|
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
|
|
"relay_control_stale_terminating",
|
|
);
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
socket.ping();
|
|
} catch (error) {
|
|
relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}, CONTROL_PING_INTERVAL_MS);
|
|
try {
|
|
socket.ping();
|
|
} catch (error) {
|
|
relayLogger.warn({ err: error, connectionId }, "relay_control_ping_send_failed");
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
relayLogger.debug({ connectionId }, "relay_control_open_waiting_for_ready");
|
|
});
|
|
|
|
socket.on("close", (code, reason) => {
|
|
if (controlWs !== socket) return;
|
|
relayLogger.warn(
|
|
{ code, reason: reason?.toString?.(), url, connectionId },
|
|
"relay_control_disconnected",
|
|
);
|
|
controlWs = null;
|
|
if (controlKeepaliveInterval) {
|
|
clearInterval(controlKeepaliveInterval);
|
|
controlKeepaliveInterval = null;
|
|
}
|
|
if (controlReadyTimeout) {
|
|
clearTimeout(controlReadyTimeout);
|
|
controlReadyTimeout = null;
|
|
}
|
|
scheduleReconnect();
|
|
});
|
|
|
|
socket.on("error", (err) => {
|
|
if (controlWs !== socket) return;
|
|
relayLogger.warn({ err, connectionId }, "relay_error");
|
|
// close event will schedule reconnect
|
|
});
|
|
|
|
socket.on("pong", () => {
|
|
if (controlWs !== socket) return;
|
|
controlLastSeenAt = Date.now();
|
|
relayLogger.debug({ connectionId }, "relay_control_pong_received");
|
|
});
|
|
|
|
socket.on("message", (data) => {
|
|
if (controlWs !== socket) return;
|
|
controlLastSeenAt = Date.now();
|
|
const msg = tryParseControlMessage(data);
|
|
if (msg) {
|
|
markControlReady();
|
|
}
|
|
if (!msg) return;
|
|
if (msg.type === "ping") {
|
|
try {
|
|
socket.send(JSON.stringify({ type: "pong", ts: Date.now() }));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return;
|
|
}
|
|
if (msg.type === "pong") return;
|
|
if (msg.type === "sync") {
|
|
for (const clientConnectionId of msg.connectionIds) {
|
|
ensureClientDataSocket(clientConnectionId);
|
|
}
|
|
return;
|
|
}
|
|
if (msg.type === "connected") {
|
|
ensureClientDataSocket(msg.connectionId);
|
|
return;
|
|
}
|
|
if (msg.type === "disconnected") {
|
|
const existing = dataSockets.get(msg.connectionId);
|
|
if (existing) {
|
|
try {
|
|
existing.close(1001, "Client disconnected");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
dataSockets.delete(msg.connectionId);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const scheduleReconnect = (): void => {
|
|
if (stopped) return;
|
|
if (reconnectTimeout) return;
|
|
|
|
reconnectAttempt += 1;
|
|
const delayMs = Math.min(30000, 1000 * reconnectAttempt);
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectTimeout = null;
|
|
connectControl();
|
|
}, delayMs);
|
|
};
|
|
|
|
const ensureClientDataSocket = (connectionId: string): void => {
|
|
if (stopped) return;
|
|
if (!connectionId) return;
|
|
if (dataSockets.has(connectionId)) return;
|
|
|
|
const url = buildRelayWebSocketUrl({
|
|
endpoint: relayEndpoint,
|
|
useTls: relayUseTls,
|
|
serverId,
|
|
role: "server",
|
|
connectionId,
|
|
});
|
|
const socket = createWebSocket(url);
|
|
dataSockets.set(connectionId, socket);
|
|
|
|
let attached = false;
|
|
const openTimeout = setTimeout(() => {
|
|
if (stopped) return;
|
|
if (socket.readyState === WebSocket.OPEN) return;
|
|
relayLogger.warn({ connectionId }, "relay_data_open_timeout_terminating");
|
|
try {
|
|
socket.terminate();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 15_000);
|
|
|
|
socket.on("open", () => {
|
|
clearTimeout(openTimeout);
|
|
relayLogger.info({ connectionId }, "relay_data_connected");
|
|
if (attached) return;
|
|
attached = true;
|
|
const externalMetadata: ExternalSocketMetadata = {
|
|
transport: "relay",
|
|
externalSessionKey: `session:${connectionId}`,
|
|
relayConnectionId: connectionId,
|
|
};
|
|
if (daemonKeyPair) {
|
|
void attachEncryptedSocket(
|
|
socket,
|
|
daemonKeyPair,
|
|
relayLogger.child({ connectionId }),
|
|
attachSocket,
|
|
externalMetadata,
|
|
);
|
|
} else {
|
|
void attachSocket(socket, externalMetadata);
|
|
}
|
|
});
|
|
|
|
socket.on("close", (code, reason) => {
|
|
clearTimeout(openTimeout);
|
|
relayLogger.warn(
|
|
{ code, reason: reason?.toString?.(), url, connectionId },
|
|
"relay_data_disconnected",
|
|
);
|
|
if (dataSockets.get(connectionId) === socket) {
|
|
dataSockets.delete(connectionId);
|
|
}
|
|
});
|
|
|
|
socket.on("error", (err) => {
|
|
relayLogger.warn({ err, connectionId }, "relay_data_error");
|
|
});
|
|
};
|
|
|
|
connectControl();
|
|
|
|
return { stop };
|
|
}
|
|
|
|
async function attachEncryptedSocket(
|
|
socket: RelayWebSocketLike,
|
|
daemonKeyPair: KeyPair,
|
|
logger: pino.Logger,
|
|
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
|
|
metadata?: ExternalSocketMetadata,
|
|
): Promise<void> {
|
|
try {
|
|
const relayTransport = createRelayTransportAdapter(socket, logger);
|
|
const emitter = new EventEmitter();
|
|
const pendingMessages: Array<string | ArrayBuffer> = [];
|
|
let attached = false;
|
|
const emitMessage = (data: string | ArrayBuffer) => {
|
|
if (attached) {
|
|
emitter.emit("message", data);
|
|
return;
|
|
}
|
|
pendingMessages.push(data);
|
|
};
|
|
const channel = await createDaemonChannel(relayTransport, daemonKeyPair, {
|
|
onmessage: emitMessage,
|
|
onclose: (code, reason) => emitter.emit("close", code, reason),
|
|
onerror: (error) => {
|
|
logger.warn({ err: error }, "relay_e2ee_error");
|
|
emitter.emit("error", error);
|
|
},
|
|
});
|
|
const encryptedSocket = createEncryptedRelaySocket({
|
|
channel,
|
|
emitter,
|
|
getTransportBufferedAmount: () => socket.bufferedAmount,
|
|
terminateTransport: () => socket.terminate(),
|
|
});
|
|
await attachSocket(encryptedSocket, metadata);
|
|
attached = true;
|
|
for (const message of pendingMessages) {
|
|
emitter.emit("message", message);
|
|
}
|
|
pendingMessages.length = 0;
|
|
} catch (error) {
|
|
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
|
|
try {
|
|
socket.close(1011, "E2EE handshake failed");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function createRelayTransportAdapter(
|
|
socket: RelayWebSocketLike,
|
|
logger: pino.Logger,
|
|
): RelayTransport {
|
|
const relayTransport: RelayTransport = {
|
|
send: (data) =>
|
|
new Promise<void>((resolve, reject) => {
|
|
try {
|
|
socket.send(data, (error) => {
|
|
if (!error) {
|
|
resolve();
|
|
return;
|
|
}
|
|
logger.warn({ err: error }, "relay_socket_send_failed");
|
|
reject(error);
|
|
});
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
logger.warn({ err }, "relay_socket_send_failed");
|
|
reject(err);
|
|
}
|
|
}),
|
|
close: (code?: number, reason?: string) => socket.close(code, reason),
|
|
onmessage: null,
|
|
onclose: null,
|
|
onerror: null,
|
|
};
|
|
|
|
socket.on("message", (data, isBinary) => {
|
|
const binary = isBinary === true;
|
|
relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary });
|
|
});
|
|
socket.on("close", (code, reason) => {
|
|
const closeCode = typeof code === "number" ? code : 1006;
|
|
relayTransport.onclose?.(closeCode, String(reason ?? ""));
|
|
});
|
|
socket.on("error", (err) => {
|
|
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
|
});
|
|
|
|
return relayTransport;
|
|
}
|
|
|
|
function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
|
|
if (!isBinary) {
|
|
if (typeof data === "string") return data;
|
|
const buffer = bufferFromWsData(data);
|
|
if (buffer) return buffer.toString("utf8");
|
|
return String(data);
|
|
}
|
|
|
|
if (data instanceof ArrayBuffer) return data;
|
|
|
|
const buffer = bufferFromWsData(data);
|
|
if (buffer) {
|
|
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
const out = new Uint8Array(view.byteLength);
|
|
out.set(view);
|
|
return out.buffer;
|
|
}
|
|
|
|
return String(data);
|
|
}
|
|
|
|
function bufferFromWsData(data: unknown): Buffer | null {
|
|
if (Buffer.isBuffer(data)) return data;
|
|
if (Array.isArray(data)) {
|
|
const buffers: Buffer[] = [];
|
|
for (const part of data) {
|
|
if (Buffer.isBuffer(part)) {
|
|
buffers.push(part);
|
|
} else if (part instanceof ArrayBuffer) {
|
|
buffers.push(Buffer.from(part));
|
|
} else if (ArrayBuffer.isView(part)) {
|
|
buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength));
|
|
} else if (typeof part === "string") {
|
|
buffers.push(Buffer.from(part, "utf8"));
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return Buffer.concat(buffers);
|
|
}
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
if (ArrayBuffer.isView(data)) {
|
|
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
}
|
|
return null;
|
|
}
|