feat: pairing v2 — one-tap trusted devices over LAN and private relay (#2103)
Reworks how devices connect to an OpenChamber server, end to end. Pairing v2: - One-time pairing links/QR codes (openchamber://connect?v=2) carrying a set of transport candidates (LAN/tunnel/relay) and a single-use secret redeemed server-side; no tokens embedded in links - Add-a-device dialog written for first-time users: intent-based transport choice (Anywhere / Home network only / This computer only) with plain-language descriptions, transparent fallback checkboxes, server-authoritative LAN detection, high-res QR dialog - Private relay folded into pairing as a transport candidate with a demand-driven lifecycle (enables when a relay device is paired, disables when none remain) Multi-transport devices: - A saved device holds all its transports and one token; mobile re-probes on connect, resume, and network change and hot-switches LAN<->relay seamlessly (no re-pairing, no remount, session preserved) - Desktop can import relay pairing links, switch to relay hosts through the E2EE tunnel, and restore a relay default host after relaunch Device management: - Device list (web + desktop) shows live per-device connectivity with the active transport (Connected - Local network / Relay) and platform badges (iOS/Android/macOS/Windows/Linux) - One physical device = one record: stable per-install dedupe keys across pairing and password re-login; typed pairing label names the device, paired devices name the connection by the issuing server hostname - Trusted desktop-local client manages all devices (list, revoke, clear revoked); relay host reaps dead client sockets after 3 missed keepalives Android: - LAN transport unblocked (cleartext + mixed content, mirroring iOS ATS exceptions); resume re-probe retries through network flux and silently auto-reconnects from a disconnected state
This commit is contained in:
@@ -15,7 +15,6 @@ import express from 'express';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { bytesToBase64Url } from './e2ee.js';
|
||||
|
||||
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
||||
|
||||
@@ -49,21 +48,20 @@ const envRelayUrlOverride = () => {
|
||||
/**
|
||||
* @param {{
|
||||
* crypto: typeof import('node:crypto'),
|
||||
* os: typeof import('node:os'),
|
||||
* readSettingsFromDiskMigrated: () => Promise<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} deps
|
||||
*/
|
||||
export const createRelayService = ({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
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 });
|
||||
@@ -125,6 +123,28 @@ export const createRelayService = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
@@ -140,29 +160,44 @@ export const createRelayService = ({
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
// 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();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
return {
|
||||
type: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
priority: 30,
|
||||
};
|
||||
if (includeToken) {
|
||||
const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0
|
||||
? clientLabel.trim()
|
||||
: 'Relay client';
|
||||
const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' });
|
||||
offer.token = token;
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
if (!hostClient) {
|
||||
const next = await readConfig();
|
||||
await start(next.relayUrl);
|
||||
}
|
||||
return buildPairingCandidate();
|
||||
};
|
||||
|
||||
const registerRoutes = (app) => {
|
||||
@@ -198,24 +233,15 @@ export const createRelayService = ({
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const result = await buildOffer({
|
||||
includeToken: req.body?.includeToken === true,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
startIfEnabled,
|
||||
reconcile,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
getPairingCandidate,
|
||||
ensureEnabledForPairing,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user