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
59 lines
2.3 KiB
JavaScript
59 lines
2.3 KiB
JavaScript
import { describe, expect, it } from 'bun:test';
|
|
|
|
import {
|
|
TunnelCodecError,
|
|
TunnelFrameType,
|
|
createFragmentAssembler,
|
|
decodeTunnelFrame,
|
|
encodeFragmentedMessage,
|
|
encodeTunnelFrame,
|
|
MAX_TUNNEL_PAYLOAD_BYTES,
|
|
} from './tunnel-codec.js';
|
|
|
|
describe('relay tunnel codec', () => {
|
|
it('round-trips a frame', () => {
|
|
const payload = new TextEncoder().encode('hello tunnel');
|
|
const frame = encodeTunnelFrame(TunnelFrameType.HttpRequest, 7, payload);
|
|
const decoded = decodeTunnelFrame(frame);
|
|
expect(decoded.frameType).toBe(TunnelFrameType.HttpRequest);
|
|
expect(decoded.streamId).toBe(7);
|
|
expect(decoded.hasMoreFragments).toBe(false);
|
|
expect(new TextDecoder().decode(decoded.payload)).toBe('hello tunnel');
|
|
});
|
|
|
|
it('preserves large stream ids without sign issues', () => {
|
|
const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 0xfffffffd, new Uint8Array(0));
|
|
expect(decodeTunnelFrame(frame).streamId).toBe(0xfffffffd);
|
|
});
|
|
|
|
it('rejects truncated and unknown frames', () => {
|
|
expect(() => decodeTunnelFrame(new Uint8Array([1, 2]))).toThrow(TunnelCodecError);
|
|
expect(() => decodeTunnelFrame(new Uint8Array([99, 0, 0, 0, 1]))).toThrow(TunnelCodecError);
|
|
});
|
|
|
|
it('fragments and reassembles oversized messages', () => {
|
|
const big = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 10);
|
|
for (let i = 0; i < big.length; i += 1) big[i] = i & 0xff;
|
|
const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 3, big);
|
|
expect(frames.length).toBe(3);
|
|
|
|
const assembler = createFragmentAssembler();
|
|
let result = null;
|
|
for (const frame of frames) {
|
|
result = assembler.push(decodeTunnelFrame(frame));
|
|
}
|
|
expect(result).not.toBeNull();
|
|
expect(Array.from(result)).toEqual(Array.from(big));
|
|
});
|
|
|
|
it('bounds fragment reassembly memory', () => {
|
|
const assembler = createFragmentAssembler(MAX_TUNNEL_PAYLOAD_BYTES + 1);
|
|
const chunk = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES);
|
|
// First fragment fits, second pushes past the cap.
|
|
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true });
|
|
expect(() =>
|
|
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true }),
|
|
).toThrow(TunnelCodecError);
|
|
});
|
|
});
|