// 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, 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, logger = console, }) => { const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk }); let hostClient = null; let status = { state: 'disabled', lastError: null, connectedClients: 0 }; 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 start = async (relayUrl) => { if (hostClient) return; const identity = await identityRuntime.getRelayIdentity(); hostClient = startRelayHost({ relayUrl, identity, getLocalPort, logger, onStatus: (next) => { status = next; }, }); status = hostClient.getStatus(); }; const stop = () => { if (!hostClient) return; hostClient.stop(); hostClient = null; 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}`); } }; const getStatus = async () => { const config = await readConfig(); const identity = await identityRuntime.getRelayIdentity(); const live = hostClient ? hostClient.getStatus() : status; return { enabled: config.enabled, state: hostClient ? live.state : '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(); await start(next.relayUrl); } 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(); await start(relayUrl); 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, getPairingCandidate, ensureEnabledForPairing, }; };