diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 10b17e34..9ed852da 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -1044,20 +1044,32 @@ let candidateRefreshInFlight = false; export const refreshActiveConnectionCandidates = async (): Promise => { if (candidateRefreshInFlight) return 'skipped'; const active = findActiveConnection(); - if (!active) return 'skipped'; + if (!active) { + logConnect('candidates:refresh-skip', { reason: 'no-active-connection' }); + return 'skipped'; + } const relay = relayCandidateOf(active); - if (!relay) return 'skipped'; + if (!relay) { + logConnect('candidates:refresh-skip', { reason: 'no-relay-candidate' }); + return 'skipped'; + } candidateRefreshInFlight = true; try { const response = await raceWithTimeout( RELAY_CONNECT_TIMEOUT_MS, runtimeFetch('/api/client-auth/connection/candidates').then((r): Response | null => r).catch(() => null), ); - if (!response?.ok) return 'skipped'; + if (!response?.ok) { + logConnect('candidates:refresh-skip', { reason: 'fetch-failed', status: response?.status ?? null }); + return 'skipped'; + } const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null; // Identity gate: the refresh must come from the server this device paired // with. Old servers (no serverId) are skipped rather than trusted blindly. - if (!payload || payload.serverId !== relay.serverId) return 'skipped'; + if (!payload || payload.serverId !== relay.serverId) { + logConnect('candidates:refresh-skip', { reason: 'server-id-mismatch' }); + return 'skipped'; + } const reported = Array.isArray(payload.candidates) ? payload.candidates : []; const lanUrls: string[] = []; for (const entry of reported) { @@ -1074,7 +1086,10 @@ export const refreshActiveConnectionCandidates = async (): Promise candidate.url.startsWith('https://')); const next: MobileTransportCandidate[] = [ ...lanUrls.map((url): MobileTransportCandidate => ({ kind: 'direct', url })), @@ -1102,6 +1117,7 @@ const scheduleCandidateRefresh = (): void => { window.setTimeout(() => { void (async () => { const result = await refreshActiveConnectionCandidates().catch((): CandidateRefreshResult => 'skipped'); + logConnect('candidates:refresh-result', { result }); if (result === 'updated' && isRelayModeActive()) { await reprobeActiveConnection().catch(() => null); } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 8e6abeb3..a74f6295 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -93,6 +93,7 @@ import { createClientPairingRuntime } from './lib/client-auth/pairing.js'; import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js'; import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createRelayService } from './lib/relay/service.js'; +import { createRelayHostLock } from './lib/relay/host-lock.js'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import webPush from 'web-push'; @@ -1476,6 +1477,14 @@ async function main(options = {}) { readSettingsStrict: readSettingsFromDiskStrict, remoteClientAuthRuntime, getLocalPort: () => tunnelRuntimeContext.getActivePort(), + // One relay host per machine: every instance sharing this data dir shares + // the relay identity (serverId), so concurrent hosts evict each other at + // the relay worker and devices land on a random local instance. + hostLock: createRelayHostLock({ + lockFilePath: path.join(OPENCHAMBER_DATA_DIR, 'relay-host.lock'), + fs, + process, + }), // Relay demand = any paired device or pending pairing session that uses the // relay transport. Drives the auto on/off lifecycle. hasRelayDemand: async () => { diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index f1bcb3df..7e33d57a 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -23,6 +23,7 @@ Host side (`packages/web/server/lib/relay/`): - `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly. - `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime. - `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection. +- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying. - `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials. - `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below). diff --git a/packages/web/server/lib/relay/host-lock.js b/packages/web/server/lib/relay/host-lock.js new file mode 100644 index 00000000..3b3fa405 --- /dev/null +++ b/packages/web/server/lib/relay/host-lock.js @@ -0,0 +1,101 @@ +// Per-machine relay-host claim. Every OpenChamber instance on a machine shares +// the same data dir and therefore the same relay signing key / serverId, so if +// two processes run a relay host at once they fight over the single host slot +// at the relay worker (each new connection closes the previous one with +// "4001: Control replaced") and paired devices land on whichever instance won +// last — often a dev/worktree instance running different code. +// +// The claim file (`relay-host.lock` in the shared data dir) makes the contest +// deterministic instead of a network race: +// - an instance only starts its relay host when there is no LIVE claimant +// (a dead claimant's stale file is ignored); +// - explicit user intent (creating a pairing link) claims unconditionally — +// the instance the user is interacting with must be the one devices reach; +// - a running host that discovers another live process has claimed backs off +// instead of reconnecting, which is what ends the replace/reconnect fight. +// +// This is a cooperative claim, not an OS lock: correctness does not depend on +// atomicity (the relay worker still enforces a single host); the claim only +// decides which process KEEPS retrying and which stands down. + +/** + * @param {{ + * lockFilePath: string, + * fs?: typeof import('node:fs'), + * process?: NodeJS.Process, + * logger?: Pick, + * }} deps + */ +export const createRelayHostLock = ({ lockFilePath, fs, process: proc, logger = console }) => { + const fsImpl = fs; + const selfPid = proc.pid; + + const isPidAlive = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + proc.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to another user — treat as + // alive; only ESRCH (no such process) means the claim is stale. + return error?.code === 'EPERM'; + } + }; + + const readClaim = () => { + try { + const raw = fsImpl.readFileSync(lockFilePath, 'utf8'); + const parsed = JSON.parse(raw); + const pid = Number(parsed?.pid); + return Number.isInteger(pid) && pid > 0 ? { pid } : null; + } catch { + // Missing file or unparsable content: no valid claim. + return null; + } + }; + + const writeClaim = () => { + try { + fsImpl.writeFileSync(lockFilePath, JSON.stringify({ pid: selfPid, claimedAt: new Date().toISOString() })); + return true; + } catch (error) { + // An unwritable data dir must not take the relay down with it — fall back + // to pre-lock behavior (start the host, let the relay worker arbitrate). + logger.warn(`[Relay] could not write host claim file: ${error?.message ?? error}`); + return true; + } + }; + + /** The pid of the current live claimant, or null when the claim is free/stale. */ + const liveClaimantPid = () => { + const claim = readClaim(); + if (!claim) return null; + return isPidAlive(claim.pid) ? claim.pid : null; + }; + + /** Claim unless another LIVE process already holds it. Re-claiming our own is a no-op refresh. */ + const tryClaim = () => { + const holder = liveClaimantPid(); + if (holder !== null && holder !== selfPid) return false; + return writeClaim(); + }; + + /** Unconditional claim — explicit user intent (pairing) overrides any holder. */ + const forceClaim = () => writeClaim(); + + /** True while this process is the live claimant. */ + const holdsClaim = () => liveClaimantPid() === selfPid; + + /** Release only our own claim; never delete another process's. */ + const release = () => { + const claim = readClaim(); + if (!claim || claim.pid !== selfPid) return; + try { + fsImpl.unlinkSync(lockFilePath); + } catch { + // Already gone or unwritable — nothing to do. + } + }; + + return { tryClaim, forceClaim, holdsClaim, liveClaimantPid, release }; +}; diff --git a/packages/web/server/lib/relay/host-lock.test.js b/packages/web/server/lib/relay/host-lock.test.js new file mode 100644 index 00000000..8b4ee61c --- /dev/null +++ b/packages/web/server/lib/relay/host-lock.test.js @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test'; + +import { createRelayHostLock } from './host-lock.js'; + +// In-memory fs standing in for the shared data dir. +const makeFakeFs = () => { + const files = new Map(); + return { + readFileSync: (p) => { + if (!files.has(p)) { + const error = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + } + return files.get(p); + }, + writeFileSync: (p, data) => { + files.set(p, data); + }, + unlinkSync: (p) => { + files.delete(p); + }, + peek: (p) => files.get(p), + }; +}; + +// Fake process: `alive` is the set of pids that respond to kill(pid, 0). +const makeFakeProcess = (pid, alive = new Set([pid])) => ({ + pid, + kill: (target) => { + if (!alive.has(target)) { + const error = new Error('ESRCH'); + error.code = 'ESRCH'; + throw error; + } + return true; + }, +}); + +const LOCK = '/data/relay-host.lock'; +const silentLogger = { warn: () => {} }; + +describe('relay host lock', () => { + it('claims a free lock and reports holding it', () => { + const fs = makeFakeFs(); + const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100), logger: silentLogger }); + + expect(lock.tryClaim()).toBe(true); + expect(lock.holdsClaim()).toBe(true); + expect(JSON.parse(fs.peek(LOCK)).pid).toBe(100); + }); + + it('refuses to claim while another LIVE process holds it', () => { + const fs = makeFakeFs(); + const alive = new Set([100, 200]); + const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger }); + const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger }); + + expect(first.tryClaim()).toBe(true); + expect(second.tryClaim()).toBe(false); + expect(second.holdsClaim()).toBe(false); + expect(second.liveClaimantPid()).toBe(100); + }); + + it('treats a dead claimant as free (stale claim takeover)', () => { + const fs = makeFakeFs(); + const alive = new Set([200]); // pid 100 is gone + fs.writeFileSync(LOCK, JSON.stringify({ pid: 100 })); + const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger }); + + expect(lock.liveClaimantPid()).toBe(null); + expect(lock.tryClaim()).toBe(true); + expect(lock.holdsClaim()).toBe(true); + }); + + it('forceClaim overrides a live holder; the loser sees the takeover', () => { + const fs = makeFakeFs(); + const alive = new Set([100, 200]); + const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger }); + const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger }); + + expect(first.tryClaim()).toBe(true); + expect(second.forceClaim()).toBe(true); + expect(second.holdsClaim()).toBe(true); + expect(first.holdsClaim()).toBe(false); + expect(first.liveClaimantPid()).toBe(200); + }); + + it('release removes only its own claim', () => { + const fs = makeFakeFs(); + const alive = new Set([100, 200]); + const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger }); + const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger }); + + expect(first.tryClaim()).toBe(true); + second.release(); // not the holder — must be a no-op + expect(first.holdsClaim()).toBe(true); + + first.release(); + expect(fs.peek(LOCK)).toBeUndefined(); + expect(first.liveClaimantPid()).toBe(null); + }); + + it('treats an unparsable claim file as free', () => { + const fs = makeFakeFs(); + fs.writeFileSync(LOCK, 'not-json'); + const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100), logger: silentLogger }); + + expect(lock.liveClaimantPid()).toBe(null); + expect(lock.tryClaim()).toBe(true); + }); + + it('an EPERM kill probe still counts as a live holder', () => { + const fs = makeFakeFs(); + fs.writeFileSync(LOCK, JSON.stringify({ pid: 100 })); + const proc = { + pid: 200, + kill: () => { + const error = new Error('EPERM'); + error.code = 'EPERM'; + throw error; + }, + }; + const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: proc, logger: silentLogger }); + + expect(lock.liveClaimantPid()).toBe(100); + expect(lock.tryClaim()).toBe(false); + }); +}); diff --git a/packages/web/server/lib/relay/service.js b/packages/web/server/lib/relay/service.js index 25dd2f88..a67b8a21 100644 --- a/packages/web/server/lib/relay/service.js +++ b/packages/web/server/lib/relay/service.js @@ -65,12 +65,21 @@ export const createRelayService = ({ // 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(); @@ -93,8 +102,65 @@ export const createRelayService = ({ }); }; - const start = async (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, @@ -106,12 +172,13 @@ export const createRelayService = ({ }, }); status = hostClient.getStatus(); + ensureClaimWatch(relayUrl); }; const stop = () => { - if (!hostClient) return; - hostClient.stop(); - hostClient = null; + stopClaimWatch(); + stopHostClient(); + if (hostLock) hostLock.release(); status = { state: 'disabled', lastError: null, connectedClients: 0 }; }; @@ -163,7 +230,9 @@ export const createRelayService = ({ const live = hostClient ? hostClient.getStatus() : status; return { enabled: config.enabled, - state: hostClient ? live.state : 'disabled', + // 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, @@ -207,7 +276,11 @@ export const createRelayService = ({ } if (!hostClient) { const next = await readConfig(); - await start(next.relayUrl); + // 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(); }; @@ -227,7 +300,8 @@ export const createRelayService = ({ const relayUrl = typeof req.body?.relayUrl === 'string' ? normalizeRelayUrl(req.body.relayUrl) : current.relayUrl; await writeConfig({ enabled: true, relayUrl }); if (hostClient) stop(); - await start(relayUrl); + // 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' });