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:
parent
1124a7f5a9
commit
328c7d7cae
|
|
@ -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).
|
||||||
|
|
|
||||||
16
Workplan.md
16
Workplan.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue