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 <noreply@anthropic.com>
This commit is contained in:
Morten V. Christiansen 2026-05-02 20:22:24 +02:00
parent 1124a7f5a9
commit 328c7d7cae
4 changed files with 111 additions and 15 deletions

View File

@ -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/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). **`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. - 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. - `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 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).

View File

@ -529,7 +529,7 @@ Exit criteria:
## Phase 9: Migrate to Phone-Mediated Wireless Validation ## 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 ### 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/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) - `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 - `k_phone/lib/proxy_service.dart`: `_handleConnect` added to `_ProxyServer`
- 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 - Dispatched from `_handleRequest` for `CONNECT` method
- Without this, HTTPS to gated hosts is blocked at Component 2 (returns 405) - Checks `_sessions.hasAnyActiveSession()` — returns 407 if no active session
- Next coding action: add `_handleConnect` to `_ProxyServer` and dispatch it from `_handleRequest` - 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) ### Verified on emulator (2026-04-29)

View File

@ -193,6 +193,8 @@ class _ProxyServer {
default: default:
await _send(req.response, 404, {'ok': false, 'error': 'not found'}); await _send(req.response, 404, {'ok': false, 'error': 'not found'});
} }
} else if (req.method == 'CONNECT') {
await _handleConnect(req);
} else { } else {
await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'}); 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}); await _send(req.response, 200, {'ok': true, 'invalidated': wasValid});
} }
// -------------------------------------------------------------------------
// CONNECT tunnel (gated HTTPS)
// -------------------------------------------------------------------------
Future<void> _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 // Resource forwarding
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -46,6 +46,34 @@ Future<({ServerSocket server, Future<Socket> socket})> _mockTcp() async {
return (server: server, socket: c.future); 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<List<int>> sub})> conn,
})> _mockComp2Tcp() async {
final server = await ServerSocket.bind('127.0.0.1', 0);
final c = Completer<({Socket socket, StreamSubscription<List<int>> sub})>();
server.listen((sock) {
if (c.isCompleted) return;
final buf = <int>[];
late StreamSubscription<List<int>> 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. // Send [request] to [proxyPort] and collect the full response.
// Assumes the server closes the connection after the response. // Assumes the server closes the connection after the response.
Future<String> _round(int proxyPort, String request) async { Future<String> _round(int proxyPort, String request) async {
@ -271,14 +299,14 @@ void main() {
group('CONNECT routing', () { group('CONNECT routing', () {
late FilterProxy proxy; late FilterProxy proxy;
late ServerSocket comp2Tcp; late ServerSocket comp2Tcp;
late Future<Socket> comp2Conn; late Future<({Socket socket, StreamSubscription<List<int>> sub})> comp2Conn;
late ServerSocket directTcp; late ServerSocket directTcp;
late Future<Socket> directConn; late Future<Socket> directConn;
setUp(() async { setUp(() async {
final c2 = await _mockTcp(); final c2 = await _mockComp2Tcp();
comp2Tcp = c2.server; comp2Tcp = c2.server;
comp2Conn = c2.socket; comp2Conn = c2.conn;
final d = await _mockTcp(); final d = await _mockTcp();
directTcp = d.server; directTcp = d.server;
@ -308,7 +336,8 @@ void main() {
expect(response, contains('200 Connection Established')); expect(response, contains('200 Connection Established'));
// Verify the tunnel endpoint is component2 // Verify the tunnel endpoint is component2
await comp2Conn.timeout(_kTimeout); final conn = await comp2Conn.timeout(_kTimeout);
conn.sub.cancel();
client.destroy(); client.destroy();
}); });
@ -351,19 +380,24 @@ void main() {
await waitClient('\r\n\r\n').timeout(_kTimeout); await waitClient('\r\n\r\n').timeout(_kTimeout);
expect(utf8.decode(clientBuf), contains('200 Connection Established')); expect(utf8.decode(clientBuf), contains('200 Connection Established'));
// Component2 side of the tunnel // Component2 side of the tunnel: mock already consumed the CONNECT
final serverSide = await comp2Conn.timeout(_kTimeout); // 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 // Client component2
final serverBuf = <int>[]; final serverBuf = <int>[];
final serverGotPing = Completer<void>(); final serverGotPing = Completer<void>();
serverSide.listen((d) { serverSub.onData((d) {
serverBuf.addAll(d); serverBuf.addAll(d);
if (!serverGotPing.isCompleted && if (!serverGotPing.isCompleted &&
utf8.decode(serverBuf, allowMalformed: true).contains('PING')) { utf8.decode(serverBuf, allowMalformed: true).contains('PING')) {
serverGotPing.complete(); serverGotPing.complete();
} }
}); });
serverSub.resume();
client.write('PING'); client.write('PING');
await client.flush(); await client.flush();