diff --git a/CHANGELOG.md b/CHANGELOG.md index dbaaa61c..06bbfa1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file. - Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing. - UI: the timeline dialog now fits small screens instead of squeezing the message list to a couple of rows (thanks to @gaojunran). - UI: the btw panel's shadow is lighter, matching the composer. +- Devices: re-pairing a phone (or logging in again) keeps the device's existing name in Connected Devices instead of resetting it to "OpenChamber Mobile". +- Relay: paired devices no longer get logged out when the app restarts (for example during an update) while another local OpenChamber process is running — the restarted app keeps serving them instead of a bystander process taking over. ## [1.20.0] - 2026-08-23 diff --git a/packages/web/package.json b/packages/web/package.json index 8da2d361..186d0cff 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,8 +13,8 @@ }, "scripts": { "dev": "bun run build:watch", - "dev:server": "bun server/index.js --port ${OPENCHAMBER_PORT:-3001}", - "dev:server:watch": "nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"", + "dev:server": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} bun server/index.js --port ${OPENCHAMBER_PORT:-3001}", + "dev:server:watch": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"", "build": "vite build", "build:watch": "vite build --watch", "type-check": "tsc --noEmit", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 31d8ff26..d91c3718 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1815,6 +1815,14 @@ async function main(options = {}) { fs, process, }), + // Dev/debug instances share the data dir (and thus the relay identity) with + // the production instance, so they must not host the relay on their own — + // paired devices would land on them. OPENCHAMBER_RELAY_HOST=off disables + // passive hosting explicitly (dev scripts set it); the Electron dev shell is + // covered via OPENCHAMBER_ELECTRON_DEV. OPENCHAMBER_RELAY_HOST=on overrides + // both. Explicit enable/pairing on the instance still hosts regardless. + allowPassiveHost: process.env.OPENCHAMBER_RELAY_HOST === 'on' + || (process.env.OPENCHAMBER_RELAY_HOST !== 'off' && process.env.OPENCHAMBER_ELECTRON_DEV !== '1'), // 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 36fa0d01..d6df917c 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -23,7 +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. +- `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. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. 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. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. 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/service.js b/packages/web/server/lib/relay/service.js index a67b8a21..bf7d99fd 100644 --- a/packages/web/server/lib/relay/service.js +++ b/packages/web/server/lib/relay/service.js @@ -70,6 +70,11 @@ export const createRelayService = ({ // 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, + // When false, this instance never starts the relay host on its own (boot, + // demand reconcile, or claim-watch takeover) — only an explicit user action + // (enable, pairing) force-claims. Dev/debug instances set this so they do not + // capture paired devices from the production instance sharing the data dir. + allowPassiveHost = true, logger = console, }) => { const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict }); @@ -80,6 +85,13 @@ export const createRelayService = ({ // claimant dies; a running host stands down when another process claims. let claimWatchTimer = null; const CLAIM_WATCH_INTERVAL_MS = 30_000; + // A standby instance does not grab a freed claim immediately: a clean restart + // of the previous host (app update, relaunch) releases the claim for a short + // while, and taking it during that window strands the devices on this — + // possibly older — instance. The restarting host reclaims at boot without any + // wait, so it always wins the window. + const CLAIM_TAKEOVER_GRACE_MS = 120_000; + let claimFreeSinceMs = null; const readConfig = async () => { const settings = await readSettingsFromDiskMigrated(); @@ -133,8 +145,19 @@ export const createRelayService = ({ } return; } - if (status.state === 'standby' && hostLock.tryClaim()) { - logger.warn('[Relay] host claim is free — taking over the relay host'); + if (status.state !== 'standby' || !allowPassiveHost) return; + if (hostLock.liveClaimantPid() !== null) { + claimFreeSinceMs = null; + return; + } + if (claimFreeSinceMs === null) { + claimFreeSinceMs = Date.now(); + return; + } + if (Date.now() - claimFreeSinceMs < CLAIM_TAKEOVER_GRACE_MS) return; + if (hostLock.tryClaim()) { + claimFreeSinceMs = null; + logger.warn('[Relay] host claim stayed free — taking over the relay host'); await start(relayUrl); } } catch (error) { @@ -149,10 +172,19 @@ export const createRelayService = ({ if (!claimWatchTimer) return; clearInterval(claimWatchTimer); claimWatchTimer = null; + claimFreeSinceMs = null; }; const start = async (relayUrl, { claim = 'try' } = {}) => { if (hostClient) return; + if (claim !== 'force' && !allowPassiveHost) { + status = { + state: 'standby', + lastError: 'passive relay hosting is disabled on this instance — enable the relay or create a pairing link to host here', + connectedClients: 0, + }; + return; + } if (hostLock) { const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim(); if (!claimed) { diff --git a/packages/web/server/lib/relay/service.test.js b/packages/web/server/lib/relay/service.test.js new file mode 100644 index 00000000..c49159e9 --- /dev/null +++ b/packages/web/server/lib/relay/service.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import crypto from 'node:crypto'; + +import { createRelayService } from './service.js'; + +const makeService = (options = {}) => { + // In-memory settings store with a pre-seeded relay identity so the service + // never regenerates a signing key during the test. + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + let settings = { + relaySigningKey: { + privateJwk: privateKey.export({ format: 'jwk' }), + publicJwk: publicKey.export({ format: 'jwk' }), + }, + privateRelay: { enabled: true, relayUrl: 'wss://relay.example.test/ws' }, + ...options.settings, + }; + const hostLock = { + tryClaim: vi.fn(() => true), + forceClaim: vi.fn(() => true), + holdsClaim: vi.fn(() => true), + liveClaimantPid: vi.fn(() => null), + release: vi.fn(), + }; + const service = createRelayService({ + crypto, + readSettingsFromDiskMigrated: async () => settings, + writeSettingsToDisk: async (next) => { settings = next; }, + readSettingsStrict: async () => settings, + getLocalPort: () => 0, + hasRelayDemand: options.hasRelayDemand ?? (async () => true), + hostLock, + allowPassiveHost: options.allowPassiveHost, + logger: { warn: () => {} }, + }); + return { service, hostLock, getSettings: () => settings }; +}; + +describe('relay service passive hosting', () => { + it('never claims or starts the host passively when passive hosting is disabled', async () => { + const { service, hostLock } = makeService({ allowPassiveHost: false }); + try { + await service.startIfEnabled(); + let status = await service.getStatus(); + expect(status.state).toBe('standby'); + expect(hostLock.tryClaim).not.toHaveBeenCalled(); + expect(hostLock.forceClaim).not.toHaveBeenCalled(); + + await service.reconcile(); + status = await service.getStatus(); + expect(status.state).toBe('standby'); + expect(status.lastError).toContain('passive relay hosting is disabled'); + expect(hostLock.tryClaim).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it('force-claims for an explicit pairing even when passive hosting is disabled', async () => { + const { service, hostLock } = makeService({ allowPassiveHost: false }); + try { + const candidate = await service.ensureEnabledForPairing(); + expect(candidate?.type).toBe('relay'); + expect(hostLock.forceClaim).toHaveBeenCalled(); + } finally { + service.stop(); + } + }); +}); diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index 47cb8855..76999a33 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -114,6 +114,9 @@ clearViteCache(); const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { OPENCHAMBER_PORT: backendPort, + // Dev backends share the relay identity with the production instance; never + // let them capture the machine's relay host on their own. + OPENCHAMBER_RELAY_HOST: process.env.OPENCHAMBER_RELAY_HOST || 'off', }); const vite = run( 'vite',