diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md index 28263db..5a5df8a 100644 --- a/docs/research/phase-0.md +++ b/docs/research/phase-0.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-09-13 +last_updated: 2026-09-14 --- # alksocks — Phase 0 (Exploration) @@ -777,6 +777,12 @@ promising approaches"): `UdpSocket` (no flow table) and composed per-target alktunnels udp-tunnels (lazy destination→tunnel table, LRU) — and measure the per-destination open cost of the composed variant empirically. + **Run 2026-09-14 — passed** (`alksocks-udp-poc`, 11 tests; findings + in `poc-udp-associate-findings.md`: the handle-swap data plane + validated end to end, both egress variants implemented + composed + per-destination open cost measured at ~0.8 ms, front-door RFC-codec + composition confirmed, select!-driven stage with the egress + readiness contract as the load-bearing API finding). 3. **noq `AsyncUdpSocket` impl POC** — the client-side story against noq 1.2: associate through a fast-socks5 server, wrap as `noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06 @@ -877,6 +883,10 @@ Candidate reading for the research specialist (to be expanded): - [ ] OQ-SK-03 (UDP ASSOCIATE) — the shape space written (including the phase-model-vs-alktty-demux analysis); resolve via research + POC #2, ADR before the first consumer + **(POC #2 run 2026-09-14 — passed; the handle-swap baseline is + POC-validated end to end, findings in + `poc-udp-associate-findings.md`; the datagram framing is + wire-stable once published — the Phase 1 ADR decision remains)** - [ ] OQ-SK-04 (fast-socks5 wasm) — the decision tree is written (Case 1: wasm wanted → fork-first; Case 2: wasm unwanted → plain dep, router path); **POC #4 verified the fork is genuinely @@ -889,6 +899,7 @@ Candidate reading for the research specialist (to be expanded): dependency posture) + POC #3 if the research is not decisive - [x] Targeted POC(s) run + summaries in `docs/research/` (OQ-SK-07 #1 + #4 complete, `poc-connect-wasm-findings.md`; - #2 UDP ASSOCIATE and #3 noq remain) + #2 UDP ASSOCIATE complete, `poc-udp-associate-findings.md`; + #3 noq remains) - [ ] Converge: recommended approach written up, ready to hand to the Architect for Phase 1 \ No newline at end of file diff --git a/docs/research/poc-udp-associate-findings.md b/docs/research/poc-udp-associate-findings.md new file mode 100644 index 0000000..d5a1daf --- /dev/null +++ b/docs/research/poc-udp-associate-findings.md @@ -0,0 +1,219 @@ +--- +status: complete +last_updated: 2026-09-14 +--- + +# alksocks POC findings — channels-native UDP ASSOCIATE + +POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate: +`alksocks-udp-poc` (standalone, `/workspace/alksocks-udp-poc/`), against +alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature), +fast-socks5 1.0.0 (crates.io). 11 tests, all pass; `cargo clippy +--all-targets -- -D warnings` and `cargo fmt --check` clean. + +Validates OQ-SK-03's design baseline (the handle swap, settled +2026-09-13): **one channel per association; `[len: u16 BE][addr + +data]` frames in-band; channel EOF ends the association; the RFC UDP +header codec lives only at vanilla-client front doors.** Both producer +egress variants are implemented behind the egress callback, and the +composed variant's per-destination open cost is measured. + +## What was validated + +1. **The handle-swap data plane — confirmed end to end.** The + ASSOCIATE conversation and the datagram stage ride the same + channel `BiStream`. The producer drives the typestate directly + (`accept_no_auth → read_command → reply_success(sentinel)`) — + `run_udp_proxy_custom` is not used (its unconditional + `udp_bind_random_port` makes it unusable on channels, per the + corrected §Prior art caveat; driving the typestate directly is the + workaround the upstream ask would retire). +2. **The in-band frame codec — confirmed.** The wire is alktunnels' + boundary-preserving length codec (`frame_datagram`/`DatagramReader` + — empty payload legal, never EOF; frames split/batch across channel + chunks transparently) over the RFC address encoding + (`TargetAddr::to_be_bytes()` — ATYP + addr + port). RSV/FRAG do not + ride the tunnel wire: fragmentation cannot even be expressed + in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the + iroh-socks5 v1 posture). The RFC-header codecs + (`new_udp_header`/`parse_udp_request`) are used only at the + front-door boundary, exactly as the baseline specifies. +3. **Per-datagram addressing, no flow table — confirmed.** One relay + loop per association; each frame carries its destination; the + local-egress variant relays through one real `UdpSocket` with + per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five + in-order datagrams round-trip over one association + (`udp_multiple_datagrams_one_association`). +4. **Association lifetime = channel lifetime — confirmed.** The + consumer's `shutdown()` half-closes the stream; the mux's + implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read + as EOF; the stage exits and the connection stays healthy (a fresh + associate succeeds immediately after). No extra control vocabulary + needed — the channels-level EOF is the association's end-of-life + signal, as the iroh-socks5 eval predicted. +5. **Control silence is structurally enforced — confirmed.** The + frame's first byte is ATYP, so any post-reply control byte parses + as a garbage ATYP and fails the association loud + (`udp_undecodable_frame_ends_association`, with the unassigned ATYP + 0x7F). The phase model needs no explicit mode byte: the reply + itself synchronizes the mode switch. +6. **Both egress variants — implemented and passing.** + - **Local egress** (`LocalEgress`): one real UDP socket per + association; per-datagram addressing; no flow table. Domain-form + destinations resolve at the egress (producer-side DNS — the ssh + `-D` semantic, POC-resolved as "the egress owns resolution"). + - **Composed egress** (`ComposedEgress`): a lazy + destination→alktunnels-udp-tunnel table (each new destination + opens one `alk/tunnel` channel with `substrate: udp`), LRU-evicted + at a cap; a relay task per tunnel drains replies into the + association's shared inbound queue. Per-target ACL comes free + (the tunnel resource's ACL governs each destination's egress). + The full stack round-trips: SOCKS5 consumer → SOCKS5 producer → + composed egress → `alk/tunnel` channel → tunnel producer → local + UDP echo (`udp_composed_egress_round_trip`). +7. **The open-cost measurement (the empirical OQ-SK-03 input).** + Per-destination tunnel open, N=20 fresh associations, in-process + loopback transport: **mean ≈ 810–825 µs, min ≈ 765–800 µs, p95 ≤ + ~1.0 ms** (three runs: 825/807/819 µs mean). Sub-millisecond; + comfortable for DNS + browsing. The 256-channel default cap + (ADR-040/041, configurable) bounds any single association's + destination fan-out; the wire space itself is `u32`. The LRU cap + + the dedup assertion (`open_count == 1` for repeated same-resource + destinations) are pinned by `udp_composed_egress_lazy_table_lru_and_open_cost`. +8. **The front door — RFC codec composition at the vanilla boundary, + confirmed.** An in-process bridge exposes the association on a real + UDP socket; the door's pump decodes RFC-header'd datagrams + (`parse_udp_request`), sends `{addr, data}` frames in-band, and + returns replies RFC-header'd (`new_udp_header`). An RFC datagram + (the shape curl/tun2proxy present) round-trips through the door + (`udp_front_door_vanilla_client_round_trip`). The full raw-pipe + + reply-rewrite control door is the crate's component-2 assembly + shape (the CONNECT front door in POC #1 already validated that + pipe shape); this test pins the UDP-specific claim: RFC codec at + the door, frames in-band, zero payload translation. +9. **CONNECT on the same registration — regression-guarded.** One ALPN + (`alk/socks5`), one op (`channels/socks5/sub`), trivial params; the + CONNECT path is the POC #1 data plane unchanged + (`connect_still_served_on_the_same_registration`). +10. **The identity-seam wiring requirement re-confirmed** for the + composed path: the tunnel producer's channel-0 accept hook must + set the opener identity (`set_identity`), or scope-gated tunnel + opens fail closed with `FORBIDDEN "authentication required"` — + the same POC #1 finding, now witnessed from the consumer side of + the composed stack. + +## Structural findings (the load-bearing discoveries) + +**The datagram stage is `select!`-driven, and the egress reply +future's readiness contract is load-bearing.** The stage runs channel +reads and the egress's reply stream concurrently (`tokio::select!`). +Three readiness hazards were hit and fixed in this POC — all of them +generalizable API-design notes for the crate's Phase 1: + +1. **The poll-then-block window loses replies.** The first iteration + interleaved "drain egress replies (non-blocking), then block on the + channel read" — the echo's reply arriving during the channel block + was never picked up. The fix is the concurrent select shape, which + is also the honest full-duplex shape (an egress may produce + unsolicited inbound datagrams at any time). +2. **An instantly-ready `Ok(None)` starves the other branch with + `biased`.** A `next_reply` that returns `Ok(None)` immediately + (drained) makes the biased select take it every iteration — a + livelock that starves the mux/demux on a current-thread runtime. + The egress contract is therefore: *`next_reply` must be `Pending` + when no reply is ready* (`Ok(None)` is a spurious wakeup, not a hot + loop). The `LocalEgress` parks on `readable()` — note `writable()` + is the wrong readiness for UDP (always ready, the send-readiness, + spins forever); `readable()` is the datagram-arrival readiness. + The `ComposedEgress` parks on the shared inbound queue's `recv()`; + a closed queue (all relay tasks gone) must end the association (an + error), never spin. +3. **The length framing is applied exactly once, by the wire layer.** + `Egress::next_reply` returns the *inner* bytes (`addr + data`); + the producer's `write_frame` adds the single length prefix. (The + first iteration framed in the egress and prefixed in the producer — + double framing; the client parsed the length field as ATYP 0x00.) + +**Double framing is the one wire bug the POC caught**, and it +localizes a Phase 1 API decision: the egress (dial-callback) layer +speaks *inner* bytes; the length codec is the wire layer's. The +consumer side mirrors it: `UdpAssociation::send_datagram` frames once, +its `DatagramReader` state (mid-frame position + batched-datagram +pending queue) persists across `recv_datagram` calls — a datagram +split across channel chunks survives a caller-driven receive loop +(the first cut dropped the reader state per call; the echo test's +2-byte-prefix chunks exposed it). + +**The per-association egress instance.** `EgressFactory` is called +once per ASSOCIATE; the datagram stage owns the egress; stage exit +drops it (composed tunnels' write halves EOF their channels; the +adopted channel entries are reaped by the sessions' `Drop`, alktunnels +ADR-005). One structural bug here: the first cut bound the opened +session to a local `let _session = ...` binding, which dropped at the +end of the insert block — tearing the channel down at birth (the +relay task saw EOF instantly). The session must live inside the table +entry. The crate's Phase 1 equivalent: the egress owns its tunnels +end-to-end, and the *compiler* should enforce it (no `_` bindings). + +## Wire/API notes for the wrapper + +- `TargetAddr::to_be_bytes()` is the in-band address encoding (ATYP + + addr + port) — the RFC UDP header minus RSV/FRAG. `unframe` decodes + it by hand (synchronously over a borrowed slice); + `fast_socks5::util::target_addr::read_address` is the async in-stream + equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are + the Phase 1 codec's direct sketch. +- `alktunnels::wire::frame_datagram`/`DatagramReader` are the length + codec, reused unchanged — the shared framing story with + alktunnels' udp substrate (per the user's codec decision: depend on + alktunnels, do not hand-roll). +- The ASSOCIATE reply's `BND.ADDR` is the sentinel `127.0.0.1:0` on + the channels path (the handle is the channel; the address is pure + RFC-compatibility surface). The consumer session receives it + (`relay_addr`) and never uses it for addressing. Only the front + door has a real address to report. +- `LocalEgress::next_reply` frames replies with the *sender's* address + (the RFC's per-datagram source semantics, `recv_from`'s `from`) — + not the original destination. For echo targets the two coincide; + a real producer would attribute by egress socket (the composed + variant's table entry carries the destination explicitly — its + replies are attributed correctly by construction). +- The composed egress's per-destination attribution: the table's + `reply_addr` is recorded at tunnel-open time (the datagram's + original destination) — the tunnel IS the per-destination + association, the eval's noted advantage of the composed shape. +- `parse_udp_request` is async (though it completes immediately over a + borrowed slice); a sync mirror (`unframe`) is used where the decode + must not be async. Phase 1 may want an upstream ask: sync pure + address-decode helpers (the codec is pure; only its signature is + async). + +## OQ status after this POC + +- **OQ-SK-03 (UDP ASSOCIATE)**: the handle-swap baseline is + POC-validated end to end — one channel per association, `{addr, + data}` frames in-band (RSV/FRAG only at front doors), channel EOF = + association lifetime, per-datagram addressing without a producer + flow table. The datagram framing is wire-stable once published — + the Phase 1 ADR has empirical ground under it. The demux-vs-phase + question (the one-way-door extensibility argument) stays an ADR + decision for Phase 1 with the first consumer in sight, but the POC + adds evidence for the phase model: no in-band control vocabulary was + needed, and post-reply garbage is refused structurally. +- **OQ-SK-01 (dial policy)**: the egress seam is the dial-callback + shape extended to the datagram stage (`EgressFactory` — the `DialFn` + analogue). Both egress variants behind the callback, the protocol + layer sees neither — the no-trait, boxed-halves precedent now holds + for the datagram plane too. +- **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL- + for-free claim is validated (each destination rides its own tunnel + channel, governed by the tunnel resource's ACL). The egress-owns-DNS + resolution posture is POC-resolved for the local variant. +- **OQ-SK-07**: #1, #2, #4 complete; #3 (noq `AsyncUdpSocket`) remains. + +## POC placement + +Standalone crate (global workspace, `/workspace/alksocks-udp-poc/`) +per the placement conventions — it composes three published crates +(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code +from this repo. Findings land here; the crate is the artifact. \ No newline at end of file