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
335 lines
12 KiB
JavaScript
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,
|
|
};
|
|
};
|