// Private relay service: config persistence, lifecycle of the relay host // client, and the /api/openchamber/relay/* management routes. // // Config lives in the server settings file as `settings.privateRelay = // { enabled, relayUrl }` (same storage precedent as tunnels/notifications). // Routes are registered with the other OpenChamber feature routes, before the // generic OpenCode proxy, and are covered by the same global UI auth gate. // // Cross-runtime parity note: relay host mode intentionally targets the web // server runtime only in v1 (Electron shares this server in-process). The VS // Code runtime does not host a relay; shared UI must treat these routes as // web-runtime capabilities. import express from 'express'; import { createRelayIdentityRuntime } from './identity.js'; import { startRelayHost } from './host-client.js'; export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws'; const isValidRelayUrl = (value) => { if (typeof value !== 'string') return false; try { const url = new URL(value.trim()); return url.protocol === 'ws:' || url.protocol === 'wss:'; } catch { return false; } }; const normalizeRelayUrl = (value) => { if (typeof value !== 'string') return DEFAULT_RELAY_URL; const trimmed = value.trim(); if (!trimmed || !isValidRelayUrl(trimmed)) return DEFAULT_RELAY_URL; return trimmed; }; // A deployment can pin the relay endpoint via env (e.g. a self-hosted relay on // your own Cloudflare account/domain). When set and valid it overrides the // stored setting entirely, so the host connection, the pairing offer, and the // status all point at it — clients then inherit it from the offer automatically. const envRelayUrlOverride = () => { const raw = process.env.OPENCHAMBER_RELAY_URL; if (typeof raw !== 'string' || !raw.trim() || !isValidRelayUrl(raw)) return null; return raw.trim(); }; /** * @param {{ * crypto: typeof import('node:crypto'), * readSettingsFromDiskMigrated: () => Promise, * writeSettingsToDisk: (settings: object) => Promise, * getLocalPort: () => number, * logger?: Pick, * }} deps */ export const createRelayService = ({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, // Strict settings reader (throws on corrupt/unreadable) gating identity // regeneration — see identity.js/signing-key.js. readSettingsStrict, getLocalPort, // Returns true when any paired device or pending pairing session uses the // relay transport. The relay lifecycle is driven purely by this demand. hasRelayDemand = async () => false, // Per-machine claim (host-lock.js): all local instances share the same // serverId, so only ONE process may run the relay host at a time or they // evict each other at the relay worker ("Control replaced") and devices land // on a random instance. Optional: without it, behavior is pre-lock. hostLock = null, // When false, this instance never starts the relay host on its own (boot, // demand reconcile, or claim-watch takeover) — only an explicit user action // (enable, pairing) force-claims. Dev/debug instances set this so they do not // capture paired devices from the production instance sharing the data dir. allowPassiveHost = true, logger = console, }) => { const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict }); let hostClient = null; let status = { state: 'disabled', lastError: null, connectedClients: 0 }; // Re-checks the claim while enabled: a standby instance takes over when the // claimant dies; a running host stands down when another process claims. let claimWatchTimer = null; const CLAIM_WATCH_INTERVAL_MS = 30_000; // A standby instance does not grab a freed claim immediately: a clean restart // of the previous host (app update, relaunch) releases the claim for a short // while, and taking it during that window strands the devices on this — // possibly older — instance. The restarting host reclaims at boot without any // wait, so it always wins the window. const CLAIM_TAKEOVER_GRACE_MS = 120_000; let claimFreeSinceMs = null; const readConfig = async () => { const settings = await readSettingsFromDiskMigrated(); const stored = settings?.privateRelay; const override = envRelayUrlOverride(); return { enabled: stored?.enabled === true, relayUrl: override ?? normalizeRelayUrl(stored?.relayUrl), // True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted // relay); the stored setting is ignored while it is set. relayUrlLocked: override !== null, }; }; const writeConfig = async (config) => { const settings = await readSettingsFromDiskMigrated(); await writeSettingsToDisk({ ...settings, privateRelay: { enabled: config.enabled === true, relayUrl: normalizeRelayUrl(config.relayUrl) }, }); }; const stopHostClient = () => { if (!hostClient) return; hostClient.stop(); hostClient = null; }; const standbyStatus = (holderPid) => ({ state: 'standby', lastError: `relay host is owned by another local OpenChamber process (pid ${holderPid})`, connectedClients: 0, }); // Claim watcher, active while the relay is enabled: // - standby → claimant died → take over (start our host); // - running → another live process claimed → stand down (stop, standby). // This back-off is what actually ends the mutual-eviction fight: the loser // must STOP reconnecting, otherwise both keep replacing each other forever. const ensureClaimWatch = (relayUrl) => { if (!hostLock || claimWatchTimer) return; claimWatchTimer = setInterval(() => { void (async () => { try { if (hostClient) { if (!hostLock.holdsClaim() && hostLock.liveClaimantPid() !== null) { logger.warn('[Relay] host claim taken by another local instance — standing down'); const holder = hostLock.liveClaimantPid(); stopHostClient(); status = standbyStatus(holder); } return; } if (status.state !== 'standby' || !allowPassiveHost) return; if (hostLock.liveClaimantPid() !== null) { claimFreeSinceMs = null; return; } if (claimFreeSinceMs === null) { claimFreeSinceMs = Date.now(); return; } if (Date.now() - claimFreeSinceMs < CLAIM_TAKEOVER_GRACE_MS) return; if (hostLock.tryClaim()) { claimFreeSinceMs = null; logger.warn('[Relay] host claim stayed free — taking over the relay host'); await start(relayUrl); } } catch (error) { logger.warn(`[Relay] claim watch failed: ${error?.message ?? error}`); } })(); }, CLAIM_WATCH_INTERVAL_MS); if (typeof claimWatchTimer.unref === 'function') claimWatchTimer.unref(); }; const stopClaimWatch = () => { if (!claimWatchTimer) return; clearInterval(claimWatchTimer); claimWatchTimer = null; claimFreeSinceMs = null; }; const start = async (relayUrl, { claim = 'try' } = {}) => { if (hostClient) return; if (claim !== 'force' && !allowPassiveHost) { status = { state: 'standby', lastError: 'passive relay hosting is disabled on this instance — enable the relay or create a pairing link to host here', connectedClients: 0, }; return; } if (hostLock) { const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim(); if (!claimed) { status = standbyStatus(hostLock.liveClaimantPid()); ensureClaimWatch(relayUrl); return; } } const identity = await identityRuntime.getRelayIdentity(); hostClient = startRelayHost({ relayUrl, identity, getLocalPort, logger, onStatus: (next) => { status = next; }, }); status = hostClient.getStatus(); ensureClaimWatch(relayUrl); }; const stop = () => { stopClaimWatch(); stopHostClient(); if (hostLock) hostLock.release(); status = { state: 'disabled', lastError: null, connectedClients: 0 }; }; const startIfEnabled = async () => { try { const config = await readConfig(); if (config.enabled) { await start(config.relayUrl); } } catch (error) { logger.warn(`[Relay] startup failed: ${error?.message ?? error}`); } }; // Drive the relay lifecycle from demand: run it when a device or pending // session uses the relay, stop it when none remain. Called on startup and after // pairing/device changes, so the operator never toggles it manually. const reconcile = async () => { try { const demand = await hasRelayDemand(); const config = await readConfig(); if (demand) { if (!config.enabled) await writeConfig({ enabled: true, relayUrl: config.relayUrl }); if (!hostClient) { const next = await readConfig(); await start(next.relayUrl); } } else { if (config.enabled) await writeConfig({ enabled: false, relayUrl: config.relayUrl }); stop(); } } catch (error) { logger.warn(`[Relay] reconcile failed: ${error?.message ?? error}`); } }; // Stable server identity (base64url SHA-256 of the canonical public signing // JWK). Derived from a public key, so it is not a secret; clients use it to // verify that a learned/probed address belongs to this server before trusting // it. Independent of whether the relay host is currently enabled. const getServerId = async () => { const identity = await identityRuntime.getRelayIdentity(); return identity.serverId; }; const getStatus = async () => { const config = await readConfig(); const identity = await identityRuntime.getRelayIdentity(); const live = hostClient ? hostClient.getStatus() : status; return { enabled: config.enabled, // Without a host client the service is either off or standing by while // another local process owns the machine's relay host claim. state: hostClient ? live.state : (status.state === 'standby' ? 'standby' : 'disabled'), serverId: identity.serverId, connectedClients: live.connectedClients, relayUrl: config.relayUrl, relayUrlLocked: config.relayUrlLocked, ...(live.lastError ? { lastError: live.lastError } : {}), }; }; // Pairing candidate for the unified connection payload (pairing v2). Relay is // just another transport: it carries the relay route + E2EE trust anchor, no // embedded token — the client redeems the one-time pairing secret over the // tunnel like any other candidate. Returns null when the host relay is off, so // callers only advertise relay when it is actually reachable. Priority is high // (tried after LAN/tunnel) since the relay path is the last-resort transport. const buildPairingCandidate = async () => { const config = await readConfig(); const identity = await identityRuntime.getRelayIdentity(); return { type: 'relay', relayUrl: config.relayUrl, serverId: identity.serverId, hostEncPubJwk: identity.hostEncPubJwk, priority: 30, }; }; const getPairingCandidate = async () => { const config = await readConfig(); if (!config.enabled) return null; return buildPairingCandidate(); }; // Enable the relay host on demand and return its pairing candidate. Creating a // relay pairing link IS the demand signal, so the relay turns itself on here // rather than requiring a separate manual toggle. Idempotent: a no-op when the // relay is already enabled and running. const ensureEnabledForPairing = async () => { const config = await readConfig(); if (!config.enabled) { await writeConfig({ enabled: true, relayUrl: config.relayUrl }); } if (!hostClient) { const next = await readConfig(); // Force-claim: creating a pairing link is explicit user intent — the // instance the user is pairing against MUST be the one devices reach, // even if another local process currently holds the machine's claim // (its claim watcher sees the takeover and stands down). await start(next.relayUrl, { claim: 'force' }); } return buildPairingCandidate(); }; const registerRoutes = (app) => { app.get('/api/openchamber/relay/status', async (_req, res) => { try { res.json(await getStatus()); } catch (error) { res.status(500).json({ error: error?.message ?? 'Failed to read relay status' }); } }); app.post('/api/openchamber/relay/enable', express.json({ limit: '16kb' }), async (req, res) => { try { const current = await readConfig(); const relayUrl = typeof req.body?.relayUrl === 'string' ? normalizeRelayUrl(req.body.relayUrl) : current.relayUrl; await writeConfig({ enabled: true, relayUrl }); if (hostClient) stop(); // Explicit user action: take the machine's host claim like pairing does. await start(relayUrl, { claim: 'force' }); res.json(await getStatus()); } catch (error) { res.status(500).json({ error: error?.message ?? 'Failed to enable relay' }); } }); app.post('/api/openchamber/relay/disable', async (_req, res) => { try { const current = await readConfig(); await writeConfig({ enabled: false, relayUrl: current.relayUrl }); stop(); res.json(await getStatus()); } catch (error) { res.status(500).json({ error: error?.message ?? 'Failed to disable relay' }); } }); }; return { registerRoutes, startIfEnabled, reconcile, stop, getStatus, getServerId, getPairingCandidate, ensureEnabledForPairing, }; };