From 328c7d7cae2fb60f5378f10203bbb9baf5e08cae Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Sat, 2 May 2026 20:22:24 +0200 Subject: [PATCH] Add Component 2 CONNECT handler; fix CONNECT routing tests proxy_service.dart: _handleConnect gates on hasAnyActiveSession() (407 if no active session), then connects directly to the upstream external target (host:port from Host header), detaches the socket, and pipes bytes bidirectionally. k_server is not involved in CONNECT tunnels. filter_proxy_test.dart: replace _mockTcp() with _mockComp2Tcp() in the CONNECT routing group so the mock speaks the full CONNECT handshake (reads request headers, sends 200 Connection Established, pauses sub). All 21 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 +- Workplan.md | 16 +++++--- k_phone/lib/proxy_service.dart | 58 +++++++++++++++++++++++++++++ k_phone/test/filter_proxy_test.dart | 48 ++++++++++++++++++++---- 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c820a9..3088e5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP ro **`k_phone/lib/filter_proxy.dart`** — Component 1. Raw-socket HTTP proxy with gating filter. Per-connection: gated host → CONNECT or plain-HTTP relay through Component 2; non-gated → direct to target. Gated hosts loaded from `gated_hosts.txt` in app documents dir; defaults to `httpbin.org`. Use `setGatedEntries()` in tests to inject entries directly. -**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), and resource/counter endpoints. **CONNECT handler not yet implemented** — gated HTTPS tunnels currently return 405. +**`k_phone/lib/proxy_service.dart`** — Component 2. Background-service HTTP server (port 8771). Handles enrollment, session (login/status/logout), resource/counter endpoints, and CONNECT tunnels. For CONNECT: checks `hasAnyActiveSession()`, connects to the actual upstream host:port, detaches the socket, and pipes bytes bidirectionally. **`k_phone/lib/session_manager.dart`** — in-memory session store. `hasAnyActiveSession()` is the gate check for proxied traffic (personal-device model: one live session authorises all gated requests). @@ -150,4 +150,4 @@ Inter-VM transport uses `qvm-connect-tcp` localhost forwarding (not raw VM-IP ro - If CTAPHID `INIT` packets get no reply after a card reattach, a full USB power cycle recovers the transport. - `CR_SDK_CK-main` is missing role directories (`mvp`, `setup`, `components`, `samples`) required for the firmware build/flash flow (`./scripts/build_flash_mvp.sh`). `west` and `nrfjprog` must also be installed. - Phase 7 (firmware build): blocked on Chrome Roads (card vendor). -- Phase 9 (phone): Component 2 CONNECT handler not yet implemented — HTTPS to gated hosts will fail until `_handleConnect` is added to `proxy_service.dart`. +- Phase 9 (phone): Component 2 CONNECT handler implemented. HTTPS tunnels to gated hosts are gated by `hasAnyActiveSession()`; the tunnel connects to the actual upstream target (not k_server). diff --git a/Workplan.md b/Workplan.md index ff2aa9d..bc036a0 100644 --- a/Workplan.md +++ b/Workplan.md @@ -529,7 +529,7 @@ Exit criteria: ## Phase 9: Migrate to Phone-Mediated Wireless Validation -Status (2026-04-29): **ACTIVE — emulator integration verified** +Status (2026-05-02): **ACTIVE — Component 1 + Component 2 CONNECT handler complete** ### Target architecture @@ -585,12 +585,16 @@ k_phone development and testing runs on the Mac with the Android emulator and `c - `k_phone/test/filter_proxy_test.dart`: full test suite for Component 1 (gated matching, HTTP routing, CONNECT routing, edge cases) - `k_phone/test/enrollment_test.dart`: full test suite for `EnrollmentDb` (register, list, delete, persistence, update) -### Pending (Component 2 CONNECT handler) +### Work completed (2026-05-02, session 2) -- `proxy_service.dart` does not yet handle `CONNECT` method requests -- When Component 1 forwards a CONNECT for a gated host, Component 2 must: validate `hasAnyActiveSession()`, then connect to the upstream target and tunnel raw bytes -- Without this, HTTPS to gated hosts is blocked at Component 2 (returns 405) -- Next coding action: add `_handleConnect` to `_ProxyServer` and dispatch it from `_handleRequest` +- `k_phone/lib/proxy_service.dart`: `_handleConnect` added to `_ProxyServer` + - Dispatched from `_handleRequest` for `CONNECT` method + - Checks `_sessions.hasAnyActiveSession()` — returns 407 if no active session + - Extracts upstream host:port from `Host` header + - Opens TCP socket to upstream target (the real external server — httpbin.org, etc.) + - Detaches the HTTP socket (`detachSocket(writeHeaders: false)`) and writes `200 Connection Established` manually + - Pipes bytes bidirectionally: client ↔ upstream + - k_server is not involved in CONNECT tunnels; Component 2 connects directly to the real target ### Verified on emulator (2026-04-29) diff --git a/k_phone/lib/proxy_service.dart b/k_phone/lib/proxy_service.dart index 57fa3ef..ce7daf8 100644 --- a/k_phone/lib/proxy_service.dart +++ b/k_phone/lib/proxy_service.dart @@ -193,6 +193,8 @@ class _ProxyServer { default: await _send(req.response, 404, {'ok': false, 'error': 'not found'}); } + } else if (req.method == 'CONNECT') { + await _handleConnect(req); } else { await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'}); } @@ -426,6 +428,62 @@ class _ProxyServer { await _send(req.response, 200, {'ok': true, 'invalidated': wasValid}); } + // ------------------------------------------------------------------------- + // CONNECT tunnel (gated HTTPS) + // ------------------------------------------------------------------------- + + Future _handleConnect(HttpRequest req) async { + if (!_sessions.hasAnyActiveSession()) { + await _send(req.response, 407, {'ok': false, 'error': 'authentication required'}); + return; + } + + // Extract host:port from the Host header (CONNECT target is "host:port") + final hostHeader = req.headers.value('host') ?? ''; + final lastColon = hostHeader.lastIndexOf(':'); + String connectHost; + int connectPort; + if (lastColon > 0) { + connectHost = hostHeader.substring(0, lastColon); + connectPort = int.tryParse(hostHeader.substring(lastColon + 1)) ?? 443; + } else { + connectHost = hostHeader; + connectPort = 443; + } + + if (connectHost.isEmpty) { + await _send(req.response, 400, {'ok': false, 'error': 'missing CONNECT target'}); + return; + } + + Socket upstream; + try { + upstream = await Socket.connect(connectHost, connectPort) + .timeout(const Duration(seconds: 10)); + } catch (e) { + _emit('CONNECT $connectHost:$connectPort failed: $e'); + await _send(req.response, 502, {'ok': false, 'error': 'cannot connect to upstream'}); + return; + } + + final clientSocket = await req.response.detachSocket(writeHeaders: false); + clientSocket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n')); + await clientSocket.flush(); + + clientSocket.listen( + upstream.add, + onDone: upstream.destroy, + onError: (_) => upstream.destroy(), + cancelOnError: true, + ); + upstream.listen( + clientSocket.add, + onDone: clientSocket.destroy, + onError: (_) => clientSocket.destroy(), + cancelOnError: true, + ); + } + // ------------------------------------------------------------------------- // Resource forwarding // ------------------------------------------------------------------------- diff --git a/k_phone/test/filter_proxy_test.dart b/k_phone/test/filter_proxy_test.dart index 2ad2487..974e5df 100644 --- a/k_phone/test/filter_proxy_test.dart +++ b/k_phone/test/filter_proxy_test.dart @@ -46,6 +46,34 @@ Future<({ServerSocket server, Future socket})> _mockTcp() async { return (server: server, socket: c.future); } +// Start a mock Component 2 TCP server that speaks the CONNECT handshake: +// reads incoming request headers, responds HTTP 200, then completes with +// the socket and its paused subscription for bidirectional tunnel testing. +// The subscription is paused so the caller can install its own onData +// handler before resuming, guaranteeing no data is missed. +Future<({ + ServerSocket server, + Future<({Socket socket, StreamSubscription> sub})> conn, +})> _mockComp2Tcp() async { + final server = await ServerSocket.bind('127.0.0.1', 0); + final c = Completer<({Socket socket, StreamSubscription> sub})>(); + server.listen((sock) { + if (c.isCompleted) return; + final buf = []; + late StreamSubscription> sub; + sub = sock.listen((data) { + buf.addAll(data); + if (!c.isCompleted && + utf8.decode(buf, allowMalformed: true).contains('\r\n\r\n')) { + sub.pause(); + sock.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n')); + sock.flush().then((_) => c.complete((socket: sock, sub: sub))); + } + }); + }); + return (server: server, conn: c.future); +} + // Send [request] to [proxyPort] and collect the full response. // Assumes the server closes the connection after the response. Future _round(int proxyPort, String request) async { @@ -271,14 +299,14 @@ void main() { group('CONNECT routing', () { late FilterProxy proxy; late ServerSocket comp2Tcp; - late Future comp2Conn; + late Future<({Socket socket, StreamSubscription> sub})> comp2Conn; late ServerSocket directTcp; late Future directConn; setUp(() async { - final c2 = await _mockTcp(); + final c2 = await _mockComp2Tcp(); comp2Tcp = c2.server; - comp2Conn = c2.socket; + comp2Conn = c2.conn; final d = await _mockTcp(); directTcp = d.server; @@ -308,7 +336,8 @@ void main() { expect(response, contains('200 Connection Established')); // Verify the tunnel endpoint is component2 - await comp2Conn.timeout(_kTimeout); + final conn = await comp2Conn.timeout(_kTimeout); + conn.sub.cancel(); client.destroy(); }); @@ -351,19 +380,24 @@ void main() { await waitClient('\r\n\r\n').timeout(_kTimeout); expect(utf8.decode(clientBuf), contains('200 Connection Established')); - // Component2 side of the tunnel - final serverSide = await comp2Conn.timeout(_kTimeout); + // Component2 side of the tunnel: mock already consumed the CONNECT + // request and sent 200; sub is paused so we can install our handler + // before any tunnel data arrives. + final conn = await comp2Conn.timeout(_kTimeout); + final serverSide = conn.socket; + final serverSub = conn.sub; // Client → component2 final serverBuf = []; final serverGotPing = Completer(); - serverSide.listen((d) { + serverSub.onData((d) { serverBuf.addAll(d); if (!serverGotPing.isCompleted && utf8.decode(serverBuf, allowMalformed: true).contains('PING')) { serverGotPing.complete(); } }); + serverSub.resume(); client.write('PING'); await client.flush();