Files
openchamber/packages/ui/src/lib/runtime-switch.ts
T
Bohdan Triapitsyn 859b4529da 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
2026-07-08 03:44:02 +03:00

140 lines
5.5 KiB
TypeScript

import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth';
import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
import {
activateRelayTunnel,
deactivateRelayTunnel,
getActiveRelayTunnel,
type RelayRuntimeDescriptor,
} from '@/lib/relay/runtime-tunnel';
export { getActiveRelayTunnel };
export type RuntimeEndpointChangedDetail = {
apiBaseUrl: string;
previousApiBaseUrl: string;
runtimeKey: string;
previousRuntimeKey: string;
};
const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed';
let activeApiBaseUrl = '';
let activeRuntimeKey = '';
const setWindowRuntimeValue = <K extends '__OPENCHAMBER_API_BASE_URL__' | '__OPENCHAMBER_CLIENT_TOKEN__' | '__OPENCHAMBER_RUNTIME_HEADERS__'>(
runtimeWindow: typeof window & {
__OPENCHAMBER_API_BASE_URL__?: string;
__OPENCHAMBER_CLIENT_TOKEN__?: string;
__OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string>;
},
key: K,
value: (typeof runtimeWindow)[K],
): void => {
try {
runtimeWindow[key] = value;
} catch {
// Electron preload exposes some initial globals through contextBridge, which
// makes them read-only. Runtime switching must still update in-memory state.
}
};
const normalizeRuntimeUrlKey = (value: string): string => {
try {
const url = new URL(value);
url.hash = '';
url.search = '';
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.toString().replace(/\/+$/, '')}`;
} catch {
return `url:${value.trim().replace(/\/+$/, '') || 'default'}`;
}
};
const readInjectedApiBaseUrl = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
return typeof injected === 'string' ? injected.trim() : '';
};
const readInjectedLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
return typeof injected === 'string' ? injected.trim() : '';
};
const sameOrigin = (left: string, right: string): boolean => {
if (!left || !right) return false;
try {
return new URL(left).origin === new URL(right).origin;
} catch {
return false;
}
};
export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl();
export const getRuntimeKey = (): string => {
if (activeRuntimeKey) return activeRuntimeKey;
const apiBaseUrl = getRuntimeApiBaseUrl();
if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local';
return normalizeRuntimeUrlKey(apiBaseUrl);
};
export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => {
if (activeApiBaseUrl || activeRuntimeKey) {
return;
}
const apiBaseUrl = options.apiBaseUrl?.trim() || readInjectedApiBaseUrl();
if (!apiBaseUrl) {
return;
}
activeApiBaseUrl = apiBaseUrl;
activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl));
};
export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record<string, string> | null; relay?: RelayRuntimeDescriptor | null }): void => {
const apiBaseUrl = options.apiBaseUrl.trim();
const previousApiBaseUrl = getRuntimeApiBaseUrl();
const previousRuntimeKey = getRuntimeKey();
const runtimeKey = options.runtimeKey?.trim() || normalizeRuntimeUrlKey(apiBaseUrl);
activeApiBaseUrl = apiBaseUrl;
activeRuntimeKey = runtimeKey;
if (typeof window !== 'undefined') {
const runtimeWindow = window as typeof window & {
__OPENCHAMBER_API_BASE_URL__?: string;
__OPENCHAMBER_CLIENT_TOKEN__?: string;
__OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string>;
};
setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', apiBaseUrl);
setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', options.clientToken || undefined);
setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', options.requestHeaders || undefined);
}
configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl });
setRuntimeExtraHeaders(options.requestHeaders || null);
setRuntimeBearerToken(options.clientToken || null);
// Relay mode routes runtime HTTP/WS through an E2EE tunnel instead of the
// network. Activate the tunnel BEFORE minting the url token, since the mint
// itself rides the tunnel (runtimeFetch -> tunnel.fetch).
if (options.relay) {
activateRelayTunnel(options.relay);
} else {
deactivateRelayTunnel();
}
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_CHANGED_EVENT, {
detail: { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey },
}));
}
};
export const subscribeRuntimeEndpointChanged = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
callback((event as CustomEvent<RuntimeEndpointChangedDetail>).detail);
};
window.addEventListener(RUNTIME_ENDPOINT_CHANGED_EVENT, listener);
return () => window.removeEventListener(RUNTIME_ENDPOINT_CHANGED_EVENT, listener);
};