Files
alksocks/docs/architecture/decisions/003-udp-handle-swap.md
T
glm-5.3-flash 9587670138 docs: Phase 1 architecture — specs, ADRs 001-012, open-questions tracker
- docs/architecture/: README index, overview, wire, producer,
  consumer, vendor, bast specs + open-questions.md (the OQ-SK-01..09
  ledger promoted; new questions as OQ-SK2-06..)
- ADRs 001-012: ALPN/op/params, protocol base, the handle swap,
  the dial-callback shape, auth split by the door, access control
  (open gate + BIND command gate), BIND in base, provider defaults,
  feature gates, channel capacity, the inline vendor, target policy
- deferred OQs (SK2-06, SK2-07) with tracker tasks in
  tasks/architecture/ per the sdd_process deferred-OQ protocol
- review pass applied: frame-length joint bound (65766 corner),
  accept_no_auth vs skip_auth corrected against fast-socks5 source,
  composed-feature wasm posture, cross-reference fixes

Verified: cross-references (all ADR/OQ refs resolve), doc sizes
under split threshold, Phase 0 fidelity spot-checks
2026-09-20 18:41:56 +00:00

4.7 KiB

ADR-003: The Handle Swap — one Channel per Association, Frames In-Band

Status

Accepted (2026-09-20, Phase 1; baseline settled 2026-09-13, POC #2 validated end to end 2026-09-14)

Context

UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928 replies with a UDP relay address (BND.ADDR/BND.PORT) and the client sends datagrams to it — which seems to demand a producer-side kernel bind, the one thing the protocol layer must never do.

The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled 2026-09-13): the ASSOCIATE reply's BND.ADDR/BND.PORT is just the handle for the relay, and one relay serves the whole association — the per-datagram destination is carried in the datagram header (ATYP/addr/port), not in the handle. The relay port was never "a port that goes to one place"; it is a multiplexing endpoint. Swapping the handle (channel instead of UDP socket) preserves every RFC property. POC #2 validated the whole shape end to end, both egress variants, including the front door.

Decision

The in-band (channels-native) UDP ASSOCIATE shape is:

  • One channel per association — the CONNECT shape; no second channel, no correlation params (the op's params stay trivial, ADR-001).
  • Per-datagram destinations ride in-band frames: every datagram is [len: u16 BE][addr + data] — the boundary-preserving length codec shaped on alktunnels' (frame_datagram/ DatagramReader, re-implemented in first-party src/wire.rs; behavior pinned by the same F-2 tests) over the RFC address encoding (TargetAddr::to_be_bytes() — the RFC UDP header minus RSV/FRAG). The RFC header codec (new_udp_header/parse_udp_request) exists only at vanilla front-door boundaries. RSV/FRAG do not ride the tunnel wire — fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the iroh-socks5 v1 posture; it cannot even be expressed in-band).
  • Association lifetime = channel lifetime — channel EOF is the end-of-life signal; no extra control vocabulary (POC #2 confirmed post-reply garbage is refused structurally: the frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud).
  • The reply's BND.ADDR/BND.PORT on the in-band path are pure RFC-compatibility surface — the sentinel 127.0.0.1:0 (POC #2's shape). Only a front door with a real socket reports a real address.
  • Producer egress is the EgressFactory seam (ADR-004, the dial-callback shape): the protocol layer sees neither egress variant. The two assembly-layer variants (local socket; composed via the alktunnels direct op) are policy, not protocol.
  • The datagram stage is select!-driven with the egress readiness contract (next_reply is Pending when nothing is ready — an instantly-ready Ok(None) starves a biased select into livelock; POC #2's readiness findings are load-bearing API design for the crate). Framing is applied exactly once, by the wire layer: Egress::next_reply returns inner bytes; the producer's frame writer adds the single length prefix.
  • The consumer side mirrors the framing-once invariant, and its reader state persists. The associate session's send_datagram frames once; its recv_datagram's incremental reader state (mid-frame position + batched-datagram pending queue) persists across calls — a datagram split across channel chunks survives a caller-driven receive loop (POC #2: the first cut dropped the reader state per call; the 2-byte-prefix chunk test exposed it). This is part of the datagram stage's consumer-side surface this ADR owns.

Consequences

  • The no-bind requirement holds for all three commands: CONNECT and BIND need no socket at all; ASSOCIATE's "bind" is virtualized as the channel (the handle swap). Local binds remain optional assembly-layer shapes (front doors; the local egress variant).
  • The datagram framing is wire-stable once published — the one-way door is now closed with empirical ground under it (open cost ~0.8 ms per composed destination, measured; LRU + dedup pinned by test).
  • Boundary preservation is mandatory (empty datagram ≠ EOF — alktunnels F-2's lesson transfers directly; the frame shape meets it structurally). The BAST document for the frame is bast.md.

References

  • OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound — see ADR-010)
  • POC #2 poc-udp-associate-findings.md (the validation record + the readiness/framing/ownership findings)
  • alktunnels ADR-003 (the length codec + F-2), alktunnels ADR-007 (the direct op — the composed egress vehicle)
  • iroh-socks5 eval (docs/research/iroh-socks5-eval.md — the frame design prior art)