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
@@ -1,3 +1,5 @@
|
||||
import { getActiveRelayTunnel } from './relay/runtime-tunnel';
|
||||
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
|
||||
import { buildRuntimeAuthHeaders } from './runtime-auth';
|
||||
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
|
||||
|
||||
@@ -150,6 +152,56 @@ const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersIni
|
||||
return buildRuntimeAuthHeaders(headers);
|
||||
};
|
||||
|
||||
// ── Relay-mode routing ─────────────────────────────────────────────────────
|
||||
// When the active runtime is a private relay, runtime HTTP does not go to the
|
||||
// network: it rides the E2EE tunnel. We route exactly the same paths we would
|
||||
// resolve for a network runtime (/api, /auth, /health) and attach identical
|
||||
// auth headers; the bearer/url-token semantics are unchanged, only the
|
||||
// transport differs. Non-runtime requests (external URLs) fall through to the
|
||||
// real network fetch.
|
||||
const appendPathQuery = (path: string, query?: RuntimeUrlQuery): string => {
|
||||
if (!query) return path;
|
||||
const url = new URL(path, TUNNEL_PARSE_BASE);
|
||||
appendRuntimeQuery(url, query);
|
||||
return `${url.pathname}${url.search}`;
|
||||
};
|
||||
|
||||
const extractRelayPath = (input: string | URL | Request, query?: RuntimeUrlQuery): string | null => {
|
||||
const raw = input instanceof Request ? input.url : input.toString();
|
||||
if (!isAbsoluteUrl(raw)) {
|
||||
if (!shouldResolveApiPath(raw)) return null;
|
||||
return appendPathQuery(raw, query);
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!isCurrentWindowUrl(url) || !shouldResolveApiPath(url.pathname)) return null;
|
||||
appendRuntimeQuery(url, query);
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryRelayFetch = async (
|
||||
input: string | URL | Request,
|
||||
requestInit: RequestInit,
|
||||
query?: RuntimeUrlQuery,
|
||||
): Promise<Response | null> => {
|
||||
const relay = getActiveRelayTunnel();
|
||||
if (!relay) return null;
|
||||
const path = extractRelayPath(input, query);
|
||||
if (path === null) return null;
|
||||
const inputHeaders = input instanceof Request ? input.headers : undefined;
|
||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
|
||||
if (input instanceof Request) {
|
||||
// Forward the Request itself — the tunnel reads its method/body/signal
|
||||
// natively (incl. stream bodies). Re-wrapping as `new Request(path, input)`
|
||||
// throws on a stream body without duplex:'half'.
|
||||
return relay.fetch(input, { ...requestInit, headers });
|
||||
}
|
||||
return relay.fetch(path, { ...requestInit, headers });
|
||||
};
|
||||
|
||||
const resolveRuntimeFetchInput = (input: string | URL | Request, query?: RuntimeUrlQuery): string | URL | Request => {
|
||||
if (typeof input === 'string') {
|
||||
return buildRuntimeFetchUrl(input, query);
|
||||
@@ -192,25 +244,43 @@ const coalesceReadKey = (method: string, url: string, hasSignal: boolean): strin
|
||||
|
||||
export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise<Response> => {
|
||||
const { query, ...requestInit } = init;
|
||||
const resolvedInput = resolveRuntimeFetchInput(input, query);
|
||||
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
|
||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
|
||||
|
||||
const doFetch = (): Promise<Response> =>
|
||||
resolvedInput instanceof Request
|
||||
? fetch(new Request(resolvedInput, { ...requestInit, headers }))
|
||||
: fetch(resolvedInput, { ...requestInit, headers });
|
||||
// Resolve the transport once — relay tunnel or network — then apply the SAME
|
||||
// read-coalescing to both. On a relay the tunnel is bandwidth/latency-bound, so
|
||||
// deduping concurrent identical GETs matters there most.
|
||||
const relay = getActiveRelayTunnel();
|
||||
const relayPath = relay ? extractRelayPath(input, query) : null;
|
||||
|
||||
let doFetch: () => Promise<Response>;
|
||||
let url: string;
|
||||
let method: string;
|
||||
if (relay && relayPath !== null) {
|
||||
const inputHeaders = input instanceof Request ? input.headers : undefined;
|
||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, true);
|
||||
doFetch = input instanceof Request
|
||||
? () => relay.fetch(input, { ...requestInit, headers })
|
||||
: () => relay.fetch(relayPath, { ...requestInit, headers });
|
||||
url = relayPath;
|
||||
method = String(requestInit.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
} else {
|
||||
const resolvedInput = resolveRuntimeFetchInput(input, query);
|
||||
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
|
||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
|
||||
doFetch = resolvedInput instanceof Request
|
||||
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
|
||||
: () => fetch(resolvedInput, { ...requestInit, headers });
|
||||
url =
|
||||
resolvedInput instanceof Request ? resolvedInput.url
|
||||
: resolvedInput instanceof URL ? resolvedInput.toString()
|
||||
: String(resolvedInput);
|
||||
method = String(
|
||||
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
|
||||
).toUpperCase();
|
||||
}
|
||||
|
||||
const url =
|
||||
resolvedInput instanceof Request ? resolvedInput.url
|
||||
: resolvedInput instanceof URL ? resolvedInput.toString()
|
||||
: String(resolvedInput);
|
||||
const method = String(
|
||||
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
|
||||
).toUpperCase();
|
||||
// A Request always carries a (possibly default) signal; treat any Request, or
|
||||
// an explicit init.signal, as "has signal" and skip coalescing for safety.
|
||||
const hasSignal = requestInit.signal != null || resolvedInput instanceof Request;
|
||||
const hasSignal = requestInit.signal != null || input instanceof Request;
|
||||
|
||||
const key = coalesceReadKey(method, url, hasSignal);
|
||||
if (!key) return doFetch();
|
||||
@@ -235,6 +305,8 @@ export const installRuntimeFetchBridge = (): void => {
|
||||
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const relayResponse = await tryRelayFetch(input, init ?? {});
|
||||
if (relayResponse) return relayResponse;
|
||||
if (typeof input === 'string') {
|
||||
if (!shouldResolveFetchInput(input)) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user