feat: add private relay for end-to-end-encrypted remote access (#2087)
Adds OpenChamber Relay — an opt-in way to reach an instance from a phone, browser, or another desktop from anywhere, with no open inbound ports, no tunnel, and no shared LAN. The instance dials outbound to a relay; all app traffic (HTTP, the event stream, terminal, dictation) is multiplexed and encrypted through a single connection per client, so the relay only ever forwards opaque ciphertext. Transport - End-to-end-encrypted channel over WebCrypto (ECDH P-256 -> HKDF -> AES-256-GCM) with a capability-negotiated handshake and a small HTTP/SSE/WebSocket multiplexing protocol. A byte-compatible JS host mirror is cross-checked by tests. - Host: outbound connection manager, per-client tunnel dispatcher to the local server over loopback, reuse of the existing instance identity key, and management routes. Disabled by default; explicit opt-in. - Client: plugs into the existing runtime layer (runtime-fetch/-url/-switch/ -auth, event pipeline, terminal, dictation) so features work over the relay unchanged; direct-URL and Electron realtime-proxy paths are untouched. Pairing & UX - Relay section in Settings -> Remote Instances (live status, QR/link pairing, revocation via the existing client-token list) and the mobile connect flow. - Frame batching and idle-gated keepalive keep tunnel message volume low without affecting streaming smoothness. Security - The tunnel is transport only; the server authenticates every tunneled request exactly as for a direct remote client. fragments only. The relay stores no keys, tokens, or payloads. Operability - The endpoint can be pinned to a self-hosted rel paired clients inherit it from the offer automatically. - Relay module DOCUMENTATION.md and a relay-trans invariants that future WebSocket/streaming changes must follow. The relay transport is complete and tested; the UI for enabling and pairing is gated behind openchamber_relay_gate and stays
This commit is contained in:
committed by
GitHub
parent
42e470cefa
commit
859b4529da
@@ -0,0 +1,221 @@
|
||||
// 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';
|
||||
import { bytesToBase64Url } from './e2ee.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'),
|
||||
* 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,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
|
||||
let hostClient = null;
|
||||
let status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
||||
|
||||
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 start = async (relayUrl) => {
|
||||
if (hostClient) return;
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
hostClient = startRelayHost({
|
||||
relayUrl,
|
||||
identity,
|
||||
getLocalPort,
|
||||
logger,
|
||||
onStatus: (next) => {
|
||||
status = next;
|
||||
},
|
||||
});
|
||||
status = hostClient.getStatus();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!hostClient) return;
|
||||
hostClient.stop();
|
||||
hostClient = null;
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const live = hostClient ? hostClient.getStatus() : status;
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
state: hostClient ? live.state : 'disabled',
|
||||
serverId: identity.serverId,
|
||||
connectedClients: live.connectedClients,
|
||||
relayUrl: config.relayUrl,
|
||||
relayUrlLocked: config.relayUrlLocked,
|
||||
...(live.lastError ? { lastError: live.lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
};
|
||||
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 encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
};
|
||||
|
||||
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();
|
||||
await start(relayUrl);
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user