Files
openchamber/packages/web/server/lib/relay/service.js
T
Bohdan Triapitsyn 04307e163b fix: single relay host per machine via cooperative claim lock
All local instances share the data dir and therefore the relay identity
(serverId), so concurrent relay hosts evicted each other at the relay worker
(4001: Control replaced) and paired devices landed on whichever local process
won last — often a stale dev server, surfacing as 'Unable to reach server'
and devices stuck on relay with 503s on newer endpoints.

- relay/host-lock.js: per-machine claim file (relay-host.lock, {pid}); stale
  claims from dead pids are ignored; unwritable data dir falls back to
  pre-lock behavior
- relay/service.js: start only when the claim is free or ours, otherwise
  'standby' with the holder pid in lastError; 30s watcher takes over when the
  claimant dies and stands down when another process claims; pairing-link
  creation and explicit /relay/enable force-claim (user intent wins)
- mobileConnections.ts: log candidate-refresh skip reasons and the refresh
  result instead of failing silently
2026-07-13 00:36:00 +03:00

335 lines
12 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,
// 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,
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;
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' && hostLock.tryClaim()) {
logger.warn('[Relay] host claim is 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;
};
const start = async (relayUrl, { claim = 'try' } = {}) => {
if (hostClient) 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,
};
};