Files
openchamber/packages/web/server/lib/relay/service.js
T
Bohdan Triapitsyn afb368e11b feat: connection candidates refresh + relay identity hardening
Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
  LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
  address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
  connect/wake, hot-switch relay->LAN when a fresh address is reachable;
  serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
  back to direct; electron probe verifies serverId before authenticated fetch

Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
  returning {}; relay signing/encryption key generation is now gated on it,
  so a swallowed read failure can no longer mint a new server identity and
  orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
  (initial request racing the relay tunnel's first WS attempt, startup 5xx)
2026-07-12 18:09:54 +03:00

261 lines
9.0 KiB
JavaScript

// 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<object>,
* writeSettingsToDisk: (settings: object) => Promise<void>,
* getLocalPort: () => number,
* logger?: Pick<Console, 'warn'>,
* }} 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,
logger = console,
}) => {
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
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}`);
}
};
// 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,
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,
getServerId,
getPairingCandidate,
ensureEnabledForPairing,
};
};