From 95876701380099f3ab83432ff17d2cfb43780069 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 20 Sep 2026 18:41:56 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=201=20architecture=20=E2=80=94=20?= =?UTF-8?q?specs,=20ADRs=20001-012,=20open-questions=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/architecture/README.md | 124 +++++++++ docs/architecture/bast.md | 138 ++++++++++ docs/architecture/consumer.md | 163 ++++++++++++ .../decisions/001-alpn-and-open-op.md | 72 +++++ .../decisions/002-protocol-base.md | 62 +++++ .../decisions/003-udp-handle-swap.md | 97 +++++++ .../decisions/004-dial-callback-shape.md | 87 +++++++ .../decisions/005-auth-split-by-door.md | 86 ++++++ .../decisions/006-access-control.md | 80 ++++++ .../decisions/007-bind-in-base.md | 91 +++++++ .../decisions/008-provider-defaults.md | 78 ++++++ .../decisions/009-feature-gates.md | 81 ++++++ .../decisions/010-channel-capacity.md | 69 +++++ .../decisions/011-inline-vendor.md | 81 ++++++ .../decisions/012-target-policy.md | 85 ++++++ docs/architecture/open-questions.md | 221 ++++++++++++++++ docs/architecture/overview.md | 240 +++++++++++++++++ docs/architecture/producer.md | 180 +++++++++++++ docs/architecture/vendor.md | 105 ++++++++ docs/architecture/wire.md | 245 ++++++++++++++++++ .../oq-sk2-06-wasm-adapter-story.md | 50 ++++ .../oq-sk2-07-virtual-subnet-helper.md | 53 ++++ 22 files changed, 2488 insertions(+) create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/bast.md create mode 100644 docs/architecture/consumer.md create mode 100644 docs/architecture/decisions/001-alpn-and-open-op.md create mode 100644 docs/architecture/decisions/002-protocol-base.md create mode 100644 docs/architecture/decisions/003-udp-handle-swap.md create mode 100644 docs/architecture/decisions/004-dial-callback-shape.md create mode 100644 docs/architecture/decisions/005-auth-split-by-door.md create mode 100644 docs/architecture/decisions/006-access-control.md create mode 100644 docs/architecture/decisions/007-bind-in-base.md create mode 100644 docs/architecture/decisions/008-provider-defaults.md create mode 100644 docs/architecture/decisions/009-feature-gates.md create mode 100644 docs/architecture/decisions/010-channel-capacity.md create mode 100644 docs/architecture/decisions/011-inline-vendor.md create mode 100644 docs/architecture/decisions/012-target-policy.md create mode 100644 docs/architecture/open-questions.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/producer.md create mode 100644 docs/architecture/vendor.md create mode 100644 docs/architecture/wire.md create mode 100644 tasks/architecture/oq-sk2-06-wasm-adapter-story.md create mode 100644 tasks/architecture/oq-sk2-07-virtual-subnet-helper.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..5341c8a --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,124 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger +promoted into ADRs 001-012 and open-questions.md) +--- + +# alksocks — Architecture + +A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall +channels: an arbitrary-egress proxy service that never binds a port +unless explicitly configured to — the `alk/socks5`-family +producer/consumer protocol crate, the sibling of alktty +(`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels +realizes the `-L`/`-R` forwarding flavors, alksocks realizes the +`-D` flavor (dynamic / SOCKS5 proxy). This directory holds the +architecture spec docs and the ADRs. + +## Current State + +Phase 1 (Architecture) — initial draft. Phase 0 +(`docs/research/phase-0.md`) is converged (2026-09-14) and +POC-complete (all numbered POCs passed or were dropped with the +scope decision); the OQ-SK ledger's Phase 1 inputs are collected and +promoted into ADRs 001–012. All docs are `draft` — they transition +to `reviewed` when their open questions resolve and the architecture +review passes. `src/` does not exist yet; implementation follows the +decomposition phase. + +## Documents + +| Document | Status | Description | +|----------|--------|-------------| +| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, feature gates, module map | +| [wire.md](wire.md) | draft | The wire format: the open op (trivial params — ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels | +| [producer.md](producer.md) | draft | The producer half: the session-gate establisher, command dispatch, the three seams (dial/egress/accept), scope gates, the local backend | +| [consumer.md](consumer.md) | draft | The consumer half: the channels-native sessions (connect/associate/bind), the front doors, door auth (ADR-005), teardown ownership | +| [vendor.md](vendor.md) | draft | The inline vendor: the vendored fast-socks5 subset's scope, the four carried changes, the maintenance posture | +| [bast.md](bast.md) | draft | The BAST document for the ASSOCIATE datagram frame (ADR-003); pass-through stages have no binary framing to describe | +| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.. — the SK2 numbering is not contiguous from 01, working-draft numbers were consumed) | + +## ADR Table + +| ADR | Title | Status | +|-----|-------|--------| +| [001](decisions/001-alpn-and-open-op.md) | ALPN, Open Op, and Trivial Params | Accepted | +| [002](decisions/002-protocol-base.md) | Protocol Base — the Vendored fast-socks5 Typestate Subset | Accepted | +| [003](decisions/003-udp-handle-swap.md) | The Handle Swap — one Channel per Association, Frames In-Band | Accepted | +| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape — Function, not Trait, at Three Seams | Accepted | +| [005](decisions/005-auth-split-by-door.md) | Auth Mapping — "Both," Split by the Door | Accepted | +| [006](decisions/006-access-control.md) | Access-Control Posture — the Open Gate Plus the Command Gate | Accepted | +| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate — the Phase Model Extended | Accepted | +| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) | Accepted | +| [009](decisions/009-feature-gates.md) | Feature-Gate Layout — `net`, `local`, and the Composition Boundary | Accepted | +| [010](decisions/010-channel-capacity.md) | Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change | Accepted | +| [011](decisions/011-inline-vendor.md) | the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree | Accepted | +| [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted | + +Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own +numbers and are NOT ported — the alkcall originals at +`/workspace/@alkdev/alkcall/docs/architecture/decisions/` remain +authoritative for the shared surfaces (establishment, channels wire +format, `pump_bidi`, identity seam, the channel caps). + +## Key Design Principles + +1. **The protocol never binds.** The SOCKS5 service is a produced, + ACL-scoped resource on a channels connection ("ALPN as a + service"); kernel binds exist only as the two explicit, optional, + feature-gated assembly shapes (the producer's local backend; the + consumer's front door). The binding decision belongs to the + caller. The UDP ASSOCIATE "bind" is virtualized as the channel + (the handle swap, ADR-003). +2. **The RFC conversation is the data plane.** One ALPN, one open + op, trivial params (ADR-001); the phase model needs no mode byte + — the reply synchronizes the switch; no in-band control + vocabulary (the escape hatch is a new ALPN, never a framing + change). +3. **The seams are functions, not traits** (ADR-004): DialFn / + EgressFactory / BindAcceptFactory at the three I/O planes; the + protocol layer never names a substrate type; ownership rides the + handle structurally. +4. **Auth is split by the door** (ADR-005): producer = the identity + seam; the front door = RFC 1929 + the credential→identity static + mapping. +5. **Two grants, clean separation** (ADR-006): `socks5:open` + (egress) and `socks5:bind` (ingress listeners) — never implied by + each other; target policy = the dialer. +6. **Backpressure and limits are inherited, not redefined** (the + alkcall channels invariants); the one-channel-per-session + convention holds for all three commands; the proxy-workload cap + posture is ADR-010 (config, not wire). +7. **Wasm-clean default crate** (ADR-009): the default crate is + protocol-only; the vendored subset rides the default-on `net` + gate (ADR-011); `local` is opt-in and non-wasm; `composed` rides + alktunnels' wasm-clean default (verified with the wasm check at + implementation). +8. **The vendor is first-class** (ADR-011): the `T`-generic subset + in-tree with the four carried changes; `/workspace/fast-socks5` + stays the differential-testing reference; nothing waits on + upstream. + +## Document Lifecycle + +| Status | Meaning | Transitions | +|--------|---------|-------------| +| `draft` | Under active development; may change significantly | → `reviewed` when the doc's open questions resolve | +| `reviewed` | Architecture is final; implementation may begin | → `stable` when implementation is complete and verified | +| `stable` | Locked; changes require review, may warrant an ADR | → `deprecated` when superseded | +| `deprecated` | Superseded; kept for reference | Removed when no longer referenced | + +## References + +- Phase 0: `docs/research/phase-0.md` (the OQ ledger + convergence), + the POC findings files, `docs/research/iroh-socks5-eval.md` +- The SDD process: `docs/sdd_process.md` (Phase 1's process; the + task format; the deferred-OQ blocker-task half) +- Upstream: alkcall ADR-037/039/047/049/050/040/041/051, ledger + CF-005/CF-006; alktunnels ADR-003/004/006/007/008 + the graduation + research; alknet ADR-078/090 +- Siblings: alktty (`/workspace/@alkdev/alktty`), + alktunnels (`/workspace/@alkdev/alktunnels`) +- Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT, + third-party — the differential-testing reference) +- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`) \ No newline at end of file diff --git a/docs/architecture/bast.md b/docs/architecture/bast.md new file mode 100644 index 0000000..0c6f49d --- /dev/null +++ b/docs/architecture/bast.md @@ -0,0 +1,138 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft) +--- + +# alksocks — BAST Document for the `alk/socks5` Wire Format + +This document is the **BAST (Binary Abstract Syntax Tree)** +specification for the binary portions of the `alk/socks5` wire +format — per the alk* family BAST convention (alktunnels AGENTS.md +convention 12, carried from alktty; alktunnels `bast.md` is the +precedent), the mandatory ASSOCIATE +datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is +binary framing beyond pass-through, so the BAST document exists even +though the format is small. It is a normative JSON document +conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema` +(JSON Schema Draft 2020-12); any Draft 2020-12 validator can check +well-formedness. + +**alksocks does not depend on alktype.** The runtime codec is the +vendored fast-socks5 address encoding + the length-framing codec +(shaped on alktunnels' `frame_datagram`/`DatagramReader`, ADR-003); +this BAST document is the human-readable contract describing what +those round-trip. If runtime validation against the BAST becomes +desirable later, alktype becomes an optional dep and this document +feeds it. + +## Scope + +The `alk/socks5` wire format is: a trivial JSON open op on the +channels call plane (a UTF-8 JSON object — `{}`; specified in +[wire.md](wire.md) §The Open Op and +[ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane +inside the channel `BiStream` that is *mostly* RFC 1928 wire rules +plus raw pass-through. + +Per the BAST format spec, a BAST document describes **binary data +layouts** — not JSON payloads. The RFC 1928 conversation itself +(negotiation, command, replies, the RFC UDP datagram header at front +doors) is specified by RFC 1928 and implemented by the vendored +fast-socks5 codecs ([vendor.md](vendor.md)) — it is not re-specified +here. This document covers exactly the alksocks-specific binary +framing: + +1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`) — + the one framed stage (ADR-003). CONNECT's pass-through and BIND's + pass-through have no framing for BAST to describe. + +What this BAST deliberately does **not** cover: + +- **The RFC 1928 wire rules** — method negotiation, command/reply + encoding, the `addr` sub-layout (ATYP + addr + port), the RFC UDP + datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The + RFC is normative; the vendored codecs implement it. +- **The open-op params JSON shape** (`{}`) — UTF-8 JSON on the call + plane; ADR-001. +- **The channels 8-byte chunk header** — the channels layer's own + framing (alknet ADR-071/093); stripped transparently before the + alksocks data plane sees bytes. Specified upstream, not here. +- **The EOF sentinel** — the channels-level zero-length chunk + (upstream); the alksocks codecs never emit it (the F-2 invariant). + +## The BAST document + +```json +{ + "$schema": "https://alk.dev/bast/v1/schema", + "$defs": { + "AssociateFrame": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "len", "kind": "uint16" }, + { "name": "addr", "kind": "bytes", "encoding": "length-prefixed", + "maxLength": 259, "description": "RFC 1928 address encoding: ATYP + addr + port (TargetAddr::to_be_bytes); v4 = 1 + 4 + 2 = 7, v6 = 1 + 16 + 2 = 19, domain = 1 + 1 + 255 + 2 = 259 (the length byte of the domain form counts)" }, + { "name": "data", "kind": "bytes", "encoding": "length-prefixed", + "maxLength": 65535, "description": "the datagram payload; len == 0 is a legal empty datagram, NOT EOF" } + ] + } + } +} +``` + +(Note: `addr + data` together are exactly `len` bytes — the two +`encoding: length-prefixed` fields are the *decoder's* internal +split of one frame's payload; on the wire `len` covers both, and +`data`'s length is `len − len(addr)`, decodable because the ATYP +determines the address length. The two-field split documents the +decoder's contract, not extra wire bytes. **Joint constraint:** a +frame is legal only when `len(addr) + len(data) ≤ 65535` — the +per-field maxima are not independent (a 65507-byte datagram to a +255-char domain would need 65766 frame bytes and must be rejected +`Oversize` at frame time; wire.md §The Data Plane carries the +arithmetic).) + +## Annotations + +### `AssociateFrame` — binary, exact + +Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2 +bytes), then exactly `len` bytes = `addr + data`. The `addr` +sub-layout is the RFC 1928 address encoding as vendored +(`TargetAddr::to_be_bytes()`: ATYP `0x01` IPv4 / `0x03` domain / +`0x04` IPv6, then the address, then the u16 BE port). RSV/FRAG do +not ride the tunnel wire (ADR-003); FRAG ≠ 0 is refused at front +doors. + +One frame per datagram, in both directions of the channel +`BiStream`, for the whole datagram stage (association lifetime = +channel lifetime). Frames are self-delimiting on the byte stream; a +decoder is an incremental state machine across chunk boundaries +(boundary preservation is mandatory — an empty datagram is a frame +with `len == 0`, never EOF; alktunnels F-2's invariant, met +structurally). + +### Layering note + +The frame codec sits in the wire layer, applied **exactly once**: +`Egress::next_reply` returns *inner* bytes (`addr + data`); the +producer's frame writer adds the single length prefix (ADR-003's +framing-once invariant — the double-framing bug class POC #2 +caught). The consumer side mirrors it: the session's send frames +once, its reader state persists across receives. The BAST describes +the wire inside the `BiStream` only; `pump_bidi` (ADR-050) is +upstream's, not re-specified here. + +## References + +- [ADR-003](decisions/003-udp-handle-swap.md) (the handle swap + the + frame), [ADR-001](decisions/001-alpn-and-open-op.md) (the JSON + open-op shape — outside BAST scope), + [ADR-011](decisions/011-inline-vendor.md) (the vendored codecs) +- [wire.md](wire.md) (the normative prose; §The Data Plane) +- alktunnels `bast.md` (the BAST precedent — same meta-schema, same + "binary framing only" scope rule; the length codec's original + document) and alktunnels ADR-003 (the codec alksocks reuses) +- POC #2 `poc-udp-associate-findings.md` (the frame's validation + matrix: empty datagrams, splits/batches, garbage-ATYP refusal) \ No newline at end of file diff --git a/docs/architecture/consumer.md b/docs/architecture/consumer.md new file mode 100644 index 0000000..cbc7a8f --- /dev/null +++ b/docs/architecture/consumer.md @@ -0,0 +1,163 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft) +--- + +# alksocks — The Consumer Half + +The consumer opens SOCKS5 channels against a produced service and +speaks RFC 1928 inside them. Two consumption shapes, structurally +separate (the Phase 0 scope resolution): + +1. **The channels-native session** — a typed client that opens + `channels/socks5/sub` and drives the RFC conversation in-stream + (wrapper-aware; the POC #1 `use_stream` / POC #2 associate + session shapes). +2. **The front door** (optional, feature `local`) — a real local + endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla + clients point at; the door bridges each accepted + connection/socket onto a channel, with the RFC codec living only + at that boundary. + +Both wrap the same protocol layer; neither is required by the other. + +## The Channels-Native Sessions + +### `Socks5ChannelSession` (CONNECT) + +Opens `channels/socks5/sub` via `ChannelClient::open_channel` +(alkcall ADR-037/043), then drives the client side of the RFC +conversation in-stream over the channel's `BiStream`: + +- the vendored client typestate's `use_stream` path — generic over + the backing stream (POC #1: `Socks5Stream` impls + `AsyncRead + AsyncWrite` itself, so the session over a `BiStream` + is the intended use, not a hack); +- target construction via `ToTargetAddr` (IP-string forms parse to + IP targets; domain form passes through unresolved — ADR-012); +- after the reply, the session is a raw byte pipe — the consumer + reads/writes payload bytes directly (0 B framing, CONNECT). + +### The associate session + +The UDP ASSOCIATE client (POC #2's shape): + +- opens the channel, negotiates, sends ASSOCIATE, reads the reply — + **receives the sentinel `BND.ADDR` and never uses it for + addressing** (the handle is the channel; ADR-003); +- `send_datagram(dst, bytes)` frames once + (`[len: u16 BE][addr + data]`, the vendored address encoding) and + writes the frame; +- `recv_datagram()` returns `Some((source_addr, bytes))` per + datagram — its 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's finding); +- `shutdown()` half-closes the stream — the mux's implicit-EOF + sentinel reaches the producer's stage as EOF and the association + ends (association lifetime = channel lifetime); +- **teardown ownership**: the session owns the channel; on Drop it + closes/joins — the alktunnels ADR-005 posture (the consumer + session type owns teardown), combined with ADR-004's + ownership-rides-the-handle contract. + +### The BIND client + +The BIND reply#2 reader (POC #5's `read_reply2` sketch): the vendored +client `request()` reads reply#1; reply#2 is hand-read with the +vendored public `read_address` codec (reply#1's codec applied again; +no second codec). The session then rides the pass-through data +plane. The BIND client is a wrapper-aware-consumer convenience — +vanilla clients reach BIND through the front door instead. + +### Error surface + +`SocksServerError` / `SocksError` (the vendored surface) mapped +faithfully; channel-open failures are the typed +`channel:open_failed` reasons (alkcall ADR-049). A refused session +never yields a phantom channel; a refused target is an RFC reply +code surfaced through the session type. + +## The Front Doors (feature `local`, optional) + +The consumer's optional local exposure — the ssh `-D` shape (POC #1: +curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the +BIND door): + +- **The CONNECT/BIND door**: bind a real TCP listener; for each + accepted connection, pipe it raw onto a channel `BiStream` + (`copy_bidirectional`, **zero protocol translation** — POC #1's + literal 0 B pass-through conclusion). The door's credential half + (below) is the only RFC work it does. +- **The UDP door**: bind a real UDP socket; decode RFC-header'd + datagrams (`parse_udp_request`), send `{addr, data}` frames + in-band, return replies RFC-header'd (`new_udp_header`) — the RFC + codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the + v1 posture). +- **The BIND door's reply#2 fidelity**: the local variant's real + listener/peer addresses pass through the pipe unchanged (the + conversation is the vanilla client's own); the door adds nothing. + +### Door variants and auth (ADR-005) + +- **The NoAuth door** (POC #1's raw-pipe shape): zero protocol + translation; for loopback-only or wrapper-aware use. +- **The cred-bearing door** (RFC 1929): a vanilla client carrying + `user:pass` gets a real RFC 1929 conversation at the door; the + door maps the accepted credential to *local* configuration — + which channel identity/endpoint to open the producer channel + with — via a **static config table** (ADR-005: `user:pass →` + channel identity/endpoint selection). The SOCKS password never + crosses the channel as a SOCKS password; the channel identity + never reaches the vanilla client. The door checks credentials + itself (no vendored `AuthMethod` impl needed — the door owns the + accept loop). + +Door variant selection (NoAuth vs cred-bearing; the UDP door's bind +address) is assembly-layer configuration. + +## Wiring (Assembly Layer) + +```rust +let session = Socks5ChannelSession::connect( + &mut channel_client, target)?; +let (mut read, mut write) = session.split(); // post-reply payload pipe + +let assoc = AssociateSession::associate(&mut channel_client)?; +assoc.send_datagram(dst, &bytes).await?; +let (src, bytes) = assoc.recv_datagram().await?; + +let door = FrontDoor::builder() + .credentials(cred_table) // RFC 1929 mapping, ADR-005 + .bind("127.0.0.1:1080") + .build(); +door.serve(channel_client).await?; +``` + +## Design Decisions + +| ADR | Decision | Summary | +|-----|----------|---------| +| [001](decisions/001-alpn-and-open-op.md) | ALPN + op | the consumer opens the one op | +| [003](decisions/003-udp-handle-swap.md) | The associate session | sentinel BND.ADDR; frames once; reader state persists | +| [004](decisions/004-dial-callback-shape.md) | Ownership rides the handle | handle payloads own channel sessions; the session-teardown posture is inherited from **alktunnels ADR-005** (the consumer session type owns teardown) | +| [005](decisions/005-auth-split-by-door.md) | Door auth | RFC 1929 at the door; static credential→identity mapping | +| [007](decisions/007-bind-in-base.md) | BIND client | the reply#2 reader; vanilla clients via the door | +| [011](decisions/011-inline-vendor.md) | The vendor | the client `use_stream` path in-tree | + +## Open Questions + +Open questions are tracked in [open-questions.md](open-questions.md). +None currently blocking this document. + +## References + +- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the + `use_stream` fit), POC #2 (`poc-udp-associate-findings.md` — the + associate session, the reader-state finding, the UDP door), POC #5 + (`poc-bind-findings.md` — the reply#2 reader, the BIND door) +- Upstream: alkcall ADR-037/043 (channel open), **alktunnels + ADR-005** (consumer session owns teardown — the posture + inherited), the CF-005/CF-006 seam +- [producer.md](producer.md) (the other half), + [wire.md](wire.md) (the normative wire) \ No newline at end of file diff --git a/docs/architecture/decisions/001-alpn-and-open-op.md b/docs/architecture/decisions/001-alpn-and-open-op.md new file mode 100644 index 0000000..54c6d3f --- /dev/null +++ b/docs/architecture/decisions/001-alpn-and-open-op.md @@ -0,0 +1,72 @@ +# ADR-001: ALPN, Open Op, and Trivial Params + +## Status + +Accepted (2026-09-20, Phase 1) + +## Context + +The crate rides alkcall channels: a produced SOCKS5 resource is an +openable ALPN-op, opened by consumers via `ChannelClient` (alkcall +ADR-037/043). The ALPN string is wire-stable once published +(AGENTS.md conventions 5/16 — decide via ADR before the first +consumer). Phase 0's POCs (#1, #2, #5) all ran one ALPN +(`alk/socks5`), one open op (`channels/socks5/sub`), with trivially +empty params — the natural reading of "the RFC conversation is the +data plane": the SOCKS5 target is unknown at open time (it arrives +in-stream post-negotiation), so there is nothing for params to carry +(`poc-connect-wasm-findings.md` §Wire/API notes). + +## Decision + +- **ALPN: `alk/socks5`** (provisional → stable on first publish; + the alk/ prefix per the alkcall ADR-004 ALPN-string convention — + AGENTS.md convention 16's "ADR-004/ADR-006" shorthand cites the + naming family, of which ADR-004 is the operative one). One ALPN + per protocol. +- **One open op: `channels/socks5/sub`** — the SOCKS5 session op, + flavor-form like alktunnels' (`…/sub`; alkcall ADR-047: openable + ALPNs are operations). All three RFC 1928 commands (CONNECT, + BIND, UDP ASSOCIATE) ride this one op, selected in-stream at + command dispatch — the command is an RFC-1928 wire fact, not a + wire-format choice of ours. +- **Params: the trivial object** `{}` — schema `{type: object}`, + additionalProperties false. Nothing else is needed at open time + (the establisher is a session gate, not a dial — the target is + unknown). Extensibility: if a later need surfaces per-session + options (e.g. a requested local bind hint), that is an + **additive** params schema change while no params were ever + required; consumers that send `{}` stay valid. Params are a + self-contained JSON object identifying the produced resource + (alkcall ADR-039's shape, following alktunnels ADR-001's template). +- **Reply: `{"channel_id": }`** — the Sub-typed operation + reply, as with alktunnels. No extra reply fields in v1 (the + establishment phase is a session gate and has nothing to project; + the alkcall 0.8.0 `with_reply_field` seam remains available + additively if a future need appears). + +## Consequences + +- The wire surface is minimal and stable: one ALPN + one op + empty + params + one reply field. The one-way-door surface is the ALPN + string, the op id, and the (trivial) params shape — all frozen at + first publish. +- Command-level differentiation (CONNECT vs ASSOCIATE vs BIND) + lives in the RFC 1928 stream and in the ACL scope check at + command dispatch (see ADR-006), not in the op set. No + per-command ops — that would re-encode the RFC's own command byte + in our wire format for no benefit. +- POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on + the same registration (`connect_still_served_on_the_same_ + registration`, POC #2; test 7, POC #5). + +## References + +- OQ-SK-01 (params context), OQ-SK-03 (the handle swap — why no + correlation params), OQ-SK-08 (BIND rides the same op) +- POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding), + POC #2 `poc-udp-associate-findings.md` (one-op regression guard), + POC #5 `poc-bind-findings.md` (one-registration-three-commands) +- Upstream: alkcall ADR-037 (channel open ops), ADR-039 + (params is ALPN-specific), ADR-047 (flavor-form op ids) +- Sibling template: alktunnels ADR-001/ADR-002 \ No newline at end of file diff --git a/docs/architecture/decisions/002-protocol-base.md b/docs/architecture/decisions/002-protocol-base.md new file mode 100644 index 0000000..917ab4e --- /dev/null +++ b/docs/architecture/decisions/002-protocol-base.md @@ -0,0 +1,62 @@ +# ADR-002: Protocol Base — the Vendored fast-socks5 Typestate Subset + +## Status + +Accepted (2026-09-20, Phase 1; the inline-vendor posture is +OQ-SK-04's resolution, recorded in [ADR-011](011-inline-vendor.md)) + +## Context + +Phase 0 evaluated the protocol-base candidates: fast-socks5 (the +full-fidelity RFC 1928 implementation, explicit typestate server API, +generic over `T`), iroh-socks5 (structurally the closest sibling, but +iroh-coupled and an RFC subset), and a hand-rolled codec (rejected as +anti-prior-art — alknet's client hand-rolls and it is the known +weakness). POC #1 confirmed the load-bearing property: fast-socks5's +`Socks5ServerProtocol` accepts a channels `BiStream` +directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured +the wasm gating at ~120 cfg lines with the wasm-clean subset +functionally complete (a full RFC 1928 CONNECT conversation passes +`--no-default-features`). + +## Decision + +- **The protocol layer is built on fast-socks5's explicit typestate + API** — `Socks5ServerProtocol` (negotiate/finish_auth/ + read_command/reply_success/reply_error), the client side + `Socks5Stream::use_stream` / the associate session, and the + pure codecs (`new_udp_header`/`parse_udp_request`). +- **Only the explicit API is used.** The legacy + `Socks5Server`/`Socks5Socket`/`Incoming` bind-based API is + deprecated upstream and is out — never vendored into the default + path. +- **Base = v1.0.0, inline-vendored** (see ADR-011 for the vendor + mechanics and the four carried changes). `/workspace/fast-socks5` + remains the read-only reference for differential testing of RFC + edge cases; the vendor point is noted in the vendored code. +- **Refusals map faithfully per RFC 1928** — `ReplyError` codes + (`as_u8`/`from_u8`) are the error vocabulary at the command plane; + refusals are never collapsed into generic errors (AGENTS.md + convention 2). POC #1 validated the mapping (dial refusal → 0x05). + +## Consequences + +- The crate owns its protocol code in-tree (the vendor), so the + wasm gate, BIND, and the seam fixes ride the same tree — no + upstream waits. +- The typestate's interception points are the layering seams: + command-read (the dial/accept dispatch), `transfer` (the raw + two-pump), and — where the default handlers are unusable (UDP + ASSOCIATE's unconditional bind) — direct typestate driving. The + interception-point map is spec'd in [producer.md](../producer.md). +- iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF + association lifetime, FRAG≠0 refusal) were adopted at the design + level, not the code level (`docs/research/iroh-socks5-eval.md`). + +## References + +- OQ-SK-04 (resolved: inline vendor), OQ-SK-03 (the `run_udp_proxy_ + custom` caveat) +- `docs/research/iroh-socks5-eval.md` (the base comparison), + POC #1 + POC #4 (`poc-connect-wasm-findings.md`) +- Sibling precedent: alktunnels ADR-002 (one-ALPN option A) \ No newline at end of file diff --git a/docs/architecture/decisions/003-udp-handle-swap.md b/docs/architecture/decisions/003-udp-handle-swap.md new file mode 100644 index 0000000..7c9eab1 --- /dev/null +++ b/docs/architecture/decisions/003-udp-handle-swap.md @@ -0,0 +1,97 @@ +# 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](../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) \ No newline at end of file diff --git a/docs/architecture/decisions/004-dial-callback-shape.md b/docs/architecture/decisions/004-dial-callback-shape.md new file mode 100644 index 0000000..39a3dd5 --- /dev/null +++ b/docs/architecture/decisions/004-dial-callback-shape.md @@ -0,0 +1,87 @@ +# ADR-004: The Dial-Callback Shape — Function, not Trait, at Three Seams + +## Status + +Accepted (2026-09-20, Phase 1; shape resolved empirically — POC #1 +(2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15)) + +## Context + +The SOCKS5 protocol must be substrate-agnostic (AGENTS.md convention +7): the far side of a session may be a kernel socket, an alktunnels +channel, or any other byte/datagram source. The question is the +inversion point for the three I/O planes a SOCKS5 server touches: +the CONNECT dial, the ASSOCIATE datagram egress, and the BIND +accept. alktunnels resolved the same question for its establisher +with the no-trait finding (OQ-TN-05 → alktunnels ADR-004): "produce +boxed halves for a resource" is a function, not a trait — two real +implementations existed as callbacks, and the trait added nothing. + +The POCs extended that finding to all three SOCKS5 seams: + +- **CONNECT**: the dial cannot live in the establisher — the target + is unknown at open time (it arrives in-stream at command read). + The injected `DialFn(target) -> TargetHandle {read, write}` + closure is called at the command-read interception point + (`poc-connect-wasm-findings.md` §Structural findings). Per-target + refusal is an RFC reply code; session-level refusal stays + channels-level. +- **ASSOCIATE**: the `EgressFactory` — called once per association; + the datagram stage owns the egress; stage exit drops it + (`poc-udp-associate-findings.md` §The per-association egress + instance). +- **BIND**: the `BindAcceptFactory` — one instance per session; its + future owns the listener/tunnel and is dropped by the quiet + wait's `select!` on the control-stream branch (the client-gone + teardown; `poc-bind-findings.md` §The quiet wait is + select!-single-shot). + +## Decision + +- **No dialer/egress/accept trait.** All three seams are + function-shaped callbacks injected at registration/assembly: + - `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync` — + satisfies the `ChannelPlan` payload bound; the bound binds + handle/plan payloads downstream — the SOCKS5 establisher + itself carries no plan, `Establishment::default()`) + - `EgressFactory() -> impl Egress` (per-association instance; + `next_reply` readiness contract: `Pending` when nothing is + ready) + - `BindAcceptFactory` (per-session; the future owns its resources + end-to-end — the ownership-rides-the-handle lesson) +- **Ownership rides the handle structurally.** Plan/handle payloads + own their channel sessions and socket halves inside the payload + (the third witness of the teardown-at-birth pattern: POC #2's + egress table, alktunnels ADR-005, POC #5's `BindTargetHandle`). + The compiler should enforce it — no `_` bindings of owning + values. +- **The trait re-evaluation threshold is the same as alktunnels + ADR-004's**: revisit if three real implementations exist as trait + impls in practice. Evidence so far: local-socket and composed + providers are callbacks; the no-trait hunch is evidence-backed at + all three seams. + +## Consequences + +- The protocol layer never names a substrate type. Substrate-specific + providers live in feature-gated backend modules and are wired at + the assembly layer (see [producer.md](../producer.md) §Wiring and + the module map in [overview.md](../overview.md)). +- The composed providers (alktunnels direct-op egress, bind-first + listen accept) are assembly-layer compositions, not crate + dependencies of the protocol layer — the protocol crate depends + on alkcall only (see [overview.md](../overview.md) §Dependencies; + the composed-provider helpers that reference alktunnels types live + behind the composition feature, ADR-009). +- The seams' contracts (readiness, ownership, refusal mapping) are + load-bearing API surface, pinned by the POC findings and + re-pinned by the crate's test suite. + +## References + +- OQ-SK-01 (the dial policy — the A/B/C options; this ADR closes + the dialer-trait-vs-callback half), OQ-SK-03 (the egress seam), + OQ-SK-08 (the accept seam) +- POC #1/#2/#5 findings (the three seams' empirical records) +- alktunnels ADR-004 (the no-trait precedent), alknet ADR-078 / + alkcall ADR-050 (the two-pump contract the handles feed) \ No newline at end of file diff --git a/docs/architecture/decisions/005-auth-split-by-door.md b/docs/architecture/decisions/005-auth-split-by-door.md new file mode 100644 index 0000000..20af364 --- /dev/null +++ b/docs/architecture/decisions/005-auth-split-by-door.md @@ -0,0 +1,86 @@ +# ADR-005: Auth Mapping — "Both," Split by the Door + +## Status + +Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13 — +OQ-SK-02 — and POC #1 validated the identity seam end to end) + +## Context + +Two auth mechanisms are real: alkcall's identity seam (the per-call +opener identity arrives on the open-op hooks — alkcall ledger +CF-005/CF-006) and RFC 1929's in-band username/password. Phase 0 +resolved the posture: both mechanisms are real, each on its own side +of the door — the consumer's optional front door (where the vanilla +world meets the ACL world). A producer-side password would add +nothing the identity seam doesn't already do, and the ACL system is +strictly better than a shared secret. + +POC #1's wiring finding (the accept-side install hook must call +`channel0_conn.set_identity(resolved)` — the transport identity does +not flow to channel-0 dispatch automatically) is a producer-harness +requirement worth surfacing in the spec, not a protocol-layer +concern. + +## Decision + +- **Producer side: identity only.** The channels-native open is + authorized by the alkcall ACL/identity seam. The producer's + in-band RFC conversation runs NoAuth via the typestate's normal + path — `accept_no_auth` (the greeting and method selection still + happen; POC #2/#5's validated shape: `accept_no_auth → + read_command`). The negotiation is real but the auth decision is + vacuous in-band because authorization already happened at the + open gate. **`skip_auth_this_is_not_rfc_compliant` is excluded + from the in-band path** — it wraps a stream already marked + authenticated, skipping the greeting entirely; feeding it a + client that speaks the RFC greeting would misparse the greeting + as command bytes. (At most it belongs to a raw-pipe door that + never speaks RFC in-band; the crate's doors speak RFC.) +- **Consumer front door (component 2's optional shape): RFC 1929 + where vanilla clients need it.** The door owns credential + presentation: a vanilla client carrying `user:pass` gets a real + RFC 1929 conversation at the door. The door then maps the + accepted credential to *local* configuration — which channel + identity/endpoint to open the producer channel with — and that + identity reaches the producer via the CF-005/CF-006 seam. **The + SOCKS password never crosses the channel as a SOCKS password; the + channel identity never reaches the vanilla client.** +- **No fast-socks5 `AuthMethod` impl is required.** The door checks + credentials itself before/around handing the stream to the + typestate — the door is this crate's code; the trait was designed + for embedders who don't own the accept loop. +- **The door's credential→identity mapping shape: static config.** + The mapping is a caller-supplied configuration table + (`username/password → channel identity or endpoint selection`) + — static, declarative, assembly-layer. A pluggable lookup + (callback) remains possible as an additive extension if a + deployment needs dynamic resolution; v1 ships the static shape + because the front door's use cases (tun2proxy's fixed + `user:pass@host`, curl's `--proxy-user`) are single-credential or + small-table shapes. +- **The NoAuth door remains valid** for loopback-only or + wrapper-aware use (POC #1's raw-pipe shape — zero protocol + translation); the cred-bearing door is the same bridge plus the + greeting half. Door variant selection is an assembly-layer + choice. + +## Consequences + +- Vanilla-client compatibility at the front door is real (RFC 1929), + without polluting the channels-native path with shared secrets. +- The producer-side optional local backend (a front door in the + other direction — vanilla clients → a locally-served SOCKS5 + resource) offers the same door surface: door = vanilla↔ACL + boundary, same code shape. +- The mapping is not wire-visible: no params, no reply fields, no + ALPN changes — the door is entirely assembly-layer. + +## References + +- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved — + the mechanisms occupy different planes) +- POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring + finding; the raw-pipe front door) +- alkcall ledger CF-005/CF-006 (the identity seam); alkcall ADR-049 + (the establisher sees the per-call opener identity) \ No newline at end of file diff --git a/docs/architecture/decisions/006-access-control.md b/docs/architecture/decisions/006-access-control.md new file mode 100644 index 0000000..7a98d29 --- /dev/null +++ b/docs/architecture/decisions/006-access-control.md @@ -0,0 +1,80 @@ +# ADR-006: Access-Control Posture — the Open Gate Plus the Command Gate + +## Status + +Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape — +POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch)) + +## Context + +SOCKS5 is arbitrary-egress by nature — the open gate plus the target +policy are the security boundary (AGENTS.md convention 12). +alktunnels resolved the sibling posture (its ADR-006): whatever ACL +governs the resource governs everything reachable through it, plus +whatever policy the SOCKS5 implementation itself applies downstream. +For SOCKS5, BIND adds an *inbound* capability — the accept-side twin +of arbitrary egress — so the open gate must distinguish it +(OQ-SK-08's security note): an identity with proxy egress must not +implicitly get ingress listeners. POC #5 validated exactly this +distinction at command dispatch from the per-call opener identity. + +## Decision + +- **The open gate is the boundary.** The open op's + `AccessControl.required_scopes` is `["socks5:open"]` + (`SOCKS5_OPEN_SCOPE`) — scope-gated via + `ChannelCore::register_openable` (the registry runs the ACL + before the open handler). Scope-string convention: + `:` (`socks5:open`, `socks5:bind`) — + following alktty's `TTY_OPEN_SCOPE` / alktunnels' + `tunnel:open`/`tunnel:direct` shape. Stable once published. +- **The command gate: BIND requires `socks5:bind` at command + dispatch.** The open op carries the session-level gate; the + command-class distinction happens in-stream, at command read, + from the per-call opener identity (CF-005/CF-006): CONNECT and + UDP ASSOCIATE require only `socks5:open`; TCP BIND requires + `socks5:bind`. An identity without it gets the RFC-faithful + `ConnectionNotAllowed` (0x02) refusal while CONNECT still serves + the same identity (POC #5 test 4). +- **Target policy is the dialer's concern.** No target allowlists + in the base crate — the dial callback refuses (per-target refusal + is an RFC reply code; ADR-004's seams). The dialer-refuses shape + avoids a second policy layer (alktunnels OQ-TN-08's resolution + pattern, evidence-backed). Whatever ACL governs the socks5 + resource governs everything reachable through it, plus whatever + the dial callback enforces. +- **Per-target ACL for the composed egress comes free**: each + destination rides its own alktunnels direct-op channel governed + by the `tunnel:direct` grant's ACL (POC #2 validated); the dial + callback governs per-destination checks. +- **Ownership**: optionally consult `OwnershipProvider` for + resource ownership (alknet ADR-050's seam) — a registration-time + choice, not a protocol concern. +- **Hub relaying**: the hub's terminate-and-re-produce applies per + hop (alkcall ADR-051's `ChannelRelay`/`HubLegTemplate`); the hub's + ACL governs the final dial — the hub is the network boundary. No + socks5-specific relay work. + +## Consequences + +- Two grants, clean separation: `socks5:open` (egress) and + `socks5:bind` (ingress listeners) — never implied by each other + (the alktunnels ADR-006 Amendment 1 posture: a capability on its + own scope). +- BIND's security boundary is enforced even though BIND rides the + same op (ADR-001): the command gate is the mechanism, and it is + POC-validated (POC #5). +- The allowlist-hook variant (a declarative per-target policy at + registration) is *not* shipped in v1 — the dial callback is the + policy point; see OQ-SK-05's residue (OQ-SK2-07 in the tracker) + for the virtual-subnet translation-layer proposal, which is the + same dial-callback shape. + +## References + +- OQ-SK-05 (target policy), OQ-SK-08 (the BIND security note) +- POC #1 (the `socks5:open` gate; the identity seam), POC #5 + (the `socks5:bind` command gate), POC #2 (composed per-target ACL) +- alktunnels ADR-006 + Amendment 1 (the sibling posture), + alknet ADR-024 (registry layering), alknet ADR-050 (ownership), + alkcall ADR-042 / ADR-051 (hub relay) \ No newline at end of file diff --git a/docs/architecture/decisions/007-bind-in-base.md b/docs/architecture/decisions/007-bind-in-base.md new file mode 100644 index 0000000..0939bfc --- /dev/null +++ b/docs/architecture/decisions/007-bind-in-base.md @@ -0,0 +1,91 @@ +# ADR-007: BIND in the Base Crate — the Phase Model Extended + +## Status + +Accepted (2026-09-20, Phase 1; POC #5 validated end to end +2026-09-15) + +## Context + +fast-socks5 parses `Socks5Command::TCPBind` but every handler +refuses it; no bind machinery exists. RFC 1928 BIND is the +accept-side command (the `-R` far-side listener shape): the client +asks for a listener on the producer's network; the producer replies +#1 with the bound address, waits for exactly one inbound accept, and +replies #2 with the peer address before the data plane. The use case +that makes it real (FTP active mode is the canonical vanilla reason) +plus the composed variant's hub story were found in Phase 0 +(OQ-SK-08), and POC #5 validated the whole shape end to end — both +accept variants, the scope gate, the failure mapping, the vanilla +front door. + +Support is purely additive: the same ALPN, same op, same params, no +framing change; RFC-refusal of BIND remains valid for old consumers +(a producer that doesn't implement BIND just keeps refusing it). +Nothing here is a one-way door. + +## Decision + +- **BIND ships in the base crate** (protocol layer), phase-model + style: request → reply#1 carrying `BND.ADDR` → quiet wait (any + control byte after reply#1 is protocol garbage; the session ends — + exactly two exits: the accept, or the control stream's EOF/ + garbage/error) → exactly one inbound accept → reply#2 carrying the + peer address → raw pass-through data plane (zero per-packet + headers in-band, same as CONNECT; only the ASSOCIATE datagram + stage is framed). +- **The accept seam is `BindAcceptFactory`** (ADR-004). Two + provider-backed accept variants (plus the wrapper-aware in-band + default when neither is wired), all POC #5-validated: + 1. **Local backend binds for real** (feature-gated) — the faithful + RFC shape: a real network-B address in reply#1 (the listener's + `local_addr`), a real peer in reply#2, vanilla front-door + clients work. **This is the default accept variant** for the + optional local backend. + 2. **Composed (alktunnels listen resource)** — the listener lives + on a bind-first listen resource (`register_tunnel_bindfirst_ + openable` + `BindFirstInjections`, alktunnels 0.2.0); per- + listener ACL for free, hub-relayable. **Vanilla-fidelity with + no stand-ins** since the graduation: the factory no longer + carries an expected `bind_addr` — reply#1 advertises the real + OS-chosen `bound` (the open-op reply's `bound` field, + alkcall 0.8.0 reply projection); reply#2 carries the real peer + (the forwarded op's `peer` param). + 3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2) — + retained for completeness as the in-band default when neither + provider is wired: the handle-swap reading applied to BIND + (the reply is RFC-compatibility surface; the data plane is the + channel). +- **The establishment bound covers only the open-op round trip.** + The accept wait lives in the teardown-governed pump phase — FTP- + length BIND waits stop resolving as spurious establishment + refusals (the bind-first graduation's stated second win; + `poc-bind-findings.md` §Establishment-bound, superseded in shape). +- **Failure mapping is RFC-faithful end to end**: factory failure + (pre-reply#1) → reply#1 with the mapped reply code; accept-stage + failure (post-reply#1) → reply#2 with the mapped code, no data + plane. No phantom listeners, no phantom data planes. + +## Consequences + +- The RFC 1928 command surface is complete: CONNECT + BIND + + ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded + the other two). +- The composed accept path composes against alktunnels 0.2.0 — the + bind-first establisher + forwarded op (ADR-008) are the substrate; + the POC's pinned workaround (reply#1 first, the tunnel open rides + the accept future) is retired with the graduation. +- Old consumers see no change (the refusal was always the default); + the support is additive and gated by `socks5:bind` (ADR-006). + +## References + +- OQ-SK-08 (the full reasoning + the graduation's shape + supersession), OQ-SK-01 (the accept seam's shape), ADR-004 + (the accept seam), ADR-006 (the command gate) +- POC #5 `poc-bind-findings.md` (the validation record; the + superseded-in-shape ask ledger) +- alktunnels ADR-007/ADR-008 (the direct op; bind-first establisher + + forwarded op), alkcall ADR-049 amendment 3 (the reply + projection), alktunnels graduation research + (`alktunnels/docs/research/tunnels-graduation.md`) \ No newline at end of file diff --git a/docs/architecture/decisions/008-provider-defaults.md b/docs/architecture/decisions/008-provider-defaults.md new file mode 100644 index 0000000..2b32520 --- /dev/null +++ b/docs/architecture/decisions/008-provider-defaults.md @@ -0,0 +1,78 @@ +# ADR-008: Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) + +## Status + +Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice — +OQ-SK-01's residue — with the composed vehicle settled by +alktunnels 0.2.0 ADR-007) + +## Context + +OQ-SK-01's dialer-trait-vs-callback half is closed (ADR-004); what +remained was the **default provider set**: where does the dial +callback come from in the base crate's optional shapes? Option A +(dial via alktunnels — the composed shape), Option B (dial directly +— local TCP), Option C (injected dialer trait — rejected, ADR-004). +alktunnels 0.2.0 removed Option A's blocker (the direct op +`channels/tunnel/direct` is the dynamic-target channel — one open +per CONNECT target, what it was graduated for), and POC #2 measured +its per-destination open cost (~0.8 ms, loopback) — fine for DNS + +browsing. + +## Decision + +- **The base crate ships both provider kinds, both feature-gated:** + - **Local-socket providers** (behind the `local` feature): a real + `TcpStream` dial (CONNECT), a real `UdpSocket` egress + (ASSOCIATE, one socket per association, per-datagram + `send_to`/`recv_from` — no flow table; domain destinations + resolve at the egress, the ssh `-D` semantic), a real + `TcpListener` accept (BIND, ADR-007 variant 1). These are the + faithful-RFC shapes and the simplest assembly story. + - **Composed providers** (behind the composition feature): the + dial callback opens an alktunnels direct-op channel naming the + target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the + UDP table is a lazy destination→tunnel table (open on first + datagram, LRU-evict at a cap — POC #2's `ComposedEgress` shape). + Hub relaying is per-target terminate-and-re-produce + (alkcall ADR-051). +- **Neither provider is a dependency of the protocol layer** — + both are assembly-layer wiring (ADR-004); the features exist so + the providers ship *in the crate* rather than forcing every + deployment to hand-roll them. The feature gates and module + placement are in [overview.md](../overview.md) §Feature gates. +- **No default in the abstract**: registration always takes the + callbacks explicitly; there is no implicit "default dialer." + The feature-gated providers are convenience constructors over + the same seams. A missing callback means that command refuses + RFC-faithfully (`CommandNotSupported`-class reply), never a + panic — the command gate is the open handler's dispatch, and a + provider-less command is an unprovided capability, not an error + condition. +- **Per-target ACL is the composed variant's benefit** (each + destination rides its own `tunnel:direct`-governed channel — + ADR-006); the local variant's target policy is the dial + callback itself. + +## Consequences + +- The "no ports" story has two halves, both covered: the + consumer-facing half (the handle swap, ADR-003) and the + producing-side half (local egress behind `local`; composed egress + behind the composition feature; the protocol layer sees neither). +- The 256-channel default cap bounds composed fan-out; the + documented proxy-workload recommendation (ADR-010) covers the + deployment guidance. +- The composed egress's open cost is measured and acceptable for + the target workloads (DNS + browsing); a high-fanout deployment + that dislikes per-destination opens uses the local variant or + raises the cap (config, not wire). + +## References + +- OQ-SK-01 (the A/B choice), OQ-SK-03 variant 2 (the composed + egress), OQ-SK-05 (per-target ACL), OQ-SK-09 (the cap) +- POC #2 `poc-udp-associate-findings.md` (both variants; the open + cost), POC #5 `poc-bind-findings.md` (the composed accept) +- alktunnels ADR-007 (the direct op), alkcall ADR-051 (the hub + relay), ADR-004 (the seams), ADR-006 (the ACL posture) \ No newline at end of file diff --git a/docs/architecture/decisions/009-feature-gates.md b/docs/architecture/decisions/009-feature-gates.md new file mode 100644 index 0000000..cddb1ac --- /dev/null +++ b/docs/architecture/decisions/009-feature-gates.md @@ -0,0 +1,81 @@ +# ADR-009: Feature-Gate Layout — `net` (the Vendor's Gate), `local`, and the Composition Boundary + +## Status + +Accepted (2026-09-20, Phase 1; the wasm-clean default-crate invariant +is the alk* family posture — AGENTS.md convention 4) + +## Context + +The default crate must compile to `wasm32-unknown-unknown` (the +alktty/alktunnels precedent; the wasm shape is a sandboxed +SOCKS5 consumer — pure byte framing, no local sockets needed +client-side; phase-0 OQ-SK-04's analysis). Three groups of +platform-specific or sibling-dependent code need gating: + +1. **The vendored fast-socks5 subset's kernel-net touches** — POC #4 + measured the gate at ~120 cfg lines across 5 files, keyed on one + feature (`net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, + default-on for native; wasm users build with + `default-features = false`). +2. **The crate's own local backends** (dial/listen/egress providers + touching real sockets — ADR-008's local variants). +3. **The composed providers** (the alktunnels-composed egress/accept + helpers — ADR-008's composed variants), which need the + alktunnels dependency. + +One design constraint from OQ-SK-04's resolution: the vendored +subset's `net` gate and the crate's own backend gate are +*mechanically the same boundary* (tokio::net + socket2), but their +*ownership* differs — the vendor's gate ships with the vendored +code (v1.0.0-faithful + carried changes), the crate's local backends +are first-party code. + +## Decision + +- **The default crate is protocol-only and wasm-clean.** Module + placement per [overview.md](../overview.md): protocol, wire, + producer, consumer, vendor modules at the core; backend modules + feature-gated, never imported by the protocol layer. +- **`net` (default-on)**: the vendored subset's kernel-net gate + (POC #4's shape, carried into the vendor). Turning it off yields + the wasm-clean functional protocol subset (a full RFC 1928 + conversation passes — POC #4's verification). +- **`local` (default-off)**: the crate's first-party local backends + — the TCP dial, the UDP egress socket, the BIND listener, and the + front doors (the consumer's ssh `-D` local exposure; the + producer's local backend). Implies `net`. Non-wasm by design + (enabling it on wasm is a build error, the alktty posture). +- **`composed` (default-off)**: the composed providers over + alktunnels 0.2.0 (direct-op egress, bind-first listen accept) — + the only feature that adds the alktunnels dependency. The + protocol layer never references alktunnels types; the composed + module does, injected at the assembly layer (ADR-004/ADR-008). +- **tokio stays `default-features = false`** with the wasm-clean + subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never + `features = ["full"]`** (AGENTS.md convention 4); `net` and + `process`-shaped additions ride the features above. + +## Consequences + +- Wasm consumers (the sandboxed adapter story — a product input, + nothing waits on it) build the default crate clean; the protocol + substance *is* the wasm-relevant surface. +- The verification commands cover the gates: `cargo test` (default), + `cargo test --all-features`, and both wasm checks + (`cargo check/clippy --target wasm32-unknown-unknown`) — the + structural guard for the invariant. +- The vendor's carried changes (BIND, the `run_udp_proxy_custom` + bind seam, `new_reply` publication) live inside the `net`-gated + region where they touch kernel sockets and in the ungated + `T`-generic region where they don't (the BIND typestate driving + is `T`-generic; only the local-socket accept variant is + `net`-gated). + +## References + +- OQ-SK-04 (resolved: inline vendor — see ADR-011), OQ-SK-01/03/08 + (the providers the features gate) +- POC #4 `poc-connect-wasm-findings.md` (the measured gate), + alktty's `local` feature (the posture precedent), alktunnels + ADR-004 + overview.md (the same three-way split) \ No newline at end of file diff --git a/docs/architecture/decisions/010-channel-capacity.md b/docs/architecture/decisions/010-channel-capacity.md new file mode 100644 index 0000000..bbe684b --- /dev/null +++ b/docs/architecture/decisions/010-channel-capacity.md @@ -0,0 +1,69 @@ +# ADR-010: Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change + +## Status + +Accepted (2026-09-20, Phase 1; the analysis is OQ-SK-09's, +2026-09-14) + +## Context + +The 256-channel default cap (per-connection alkcall ADR-040; +per-identity ADR-041) is tight for a proxy workload: every proxied +flow *is* a channel (a browser or tun2proxy fan-out opens one +CONNECT channel per flow; composed UDP egress spends a downstream +channel per destination; BIND spends an accept resource per +listener). A proxy-shaped deployment will discover the cap in +production unless the crate documents the posture. The caps are +policy knobs — configurable without a wire change (the `u32` +channel-ID wire space is ~4 billion) — so the resolution is config +plus documentation, never wire. + +Considered and rejected: in-channel port multiplexing (a +`[port: u16][len][payload]` per-chunk prefix) — it re-opens the +demux-vs-phase trade ADR-003 (the handle swap) resolved, breaks +the 0-B pass-through that lets the vendored `transfer` drop in +unchanged, imposes chunk boundaries on a stream RFC 1928 treats as +unstructured, and violates the one-channel-per-SOCKS5-session +convention (AGENTS.md #10). + +## Decision + +- **No wire change — rejected permanently.** The caps are policy + knobs (alkcall ADR-040/041); the resolution is config. +- **Assembly-layer config is the mechanism**: a proxy-shaped + deployment raises the per-connection/per-identity caps at + registration; the default stays conservative for everyone else. +- **Documented proxy-workload recommendation**: the crate docs + recommend a raised cap for proxy deployments, with the memory + arithmetic — a session's memory is bounded + predictable (CONNECT/ + ASSOCIATE are bounded-buffer pumps; BIND adds one listener + one + accept), so the per-connection cap can rise safely relative to a + general-purpose deployment. A concrete recommended figure is a + docs-level tuning note, not an architecture constant. +- **Defense-in-depth posture stays layered**: ACL → per-connection + cap → per-identity cap → dial/accept policy. Relaxing the caps + leans on the rest. The per-identity DoS brake is arguably + *stronger* here (each SOCKS5 session maps to real producer-side + resources — a dial, a downstream channel, an accept listener); + the per-connection memory bound is arguably *weaker* (the three + command shapes are well-known and bounded). +- **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed + upstream channel budget that config cannot raise, the additive + escape is a *new multiplexed ALPN* — never a change to this + ALPN's framing. + +## Consequences + +- Deployments get the guidance before they hit the cap; no protocol + surface changes. +- The rejected multiplexing proposal has a recorded rationale and a + revisit trigger — the escape hatch is additive (new ALPN), never + a framing change. + +## References + +- OQ-SK-09 (the analysis), OQ-SK-03 (the demux-vs-phase trade the + rejection leans on), OQ-SK-05 (the virtual-subnet idea that + survives, recorded as OQ-SK2-07 in the tracker) +- alkcall ADR-040 (per-connection cap), ADR-041 (per-identity cap), + ADR-034 (the channels wire format) \ No newline at end of file diff --git a/docs/architecture/decisions/011-inline-vendor.md b/docs/architecture/decisions/011-inline-vendor.md new file mode 100644 index 0000000..500d9bd --- /dev/null +++ b/docs/architecture/decisions/011-inline-vendor.md @@ -0,0 +1,81 @@ +# ADR-011: the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree + +## Status + +Accepted (2026-09-20, Phase 1; OQ-SK-04's resolution — the ownership +premise corrected the same day) + +## Context + +fast-socks5 is **third-party upstream** (v1.0.0, MIT; +`/workspace/fast-socks5` is a read-only reference checkout — we do +not own it, and it has not been forked). The wrapper's needs go +beyond what it publishes: + +1. **The wasm gate** — `tokio::net` + `socket2` (via `mio`) hard-error + on `wasm32-unknown-unknown`; the default crate must compile there + (AGENTS.md convention 4). POC #4 measured the gate: ~120 changed + lines across 5 files, all `#[cfg]` insertions; the wasm-clean + subset is functionally complete (a full RFC 1928 CONNECT + conversation passes `--no-default-features`). +2. **BIND is absent** — `Socks5Command::TCPBind` is parsed but every + handler refuses it; POC #5 proved the wrapper must drive the + typestate itself for the BIND shape (ADR-007). +3. **The `run_udp_proxy_custom` bind seam** — the "custom" seam is + narrower than it looks: it calls `udp_bind_random_port` + unconditionally and derives the reply port from that kernel + socket; the channels path cannot use it (verified 2026-09-13). + The channels path drives the typestate directly; making the bind + optional / accepting a caller-supplied socket is an in-tree fix. +4. **`new_reply` is private** — the reply encoder is private; the + BIND reply#2 writer otherwise hand-rolls the 10-byte reply + (POC #5's courtesy item, now a vendor change). + +Two facts settled the posture: the ownership premise was wrong +(no cheap "file asks / PR" path), and the carried needs (2–4) exist +regardless of the wasm question — so the vendor pays for itself +without leaning on the adapter story. The change set is exactly the +shape POC #4 measured as minimal. + +## Decision + +- **Vendor the `T`-generic subset into this crate** (the codecs, the + typestate machinery, the client `use_stream` path — the parts + already generic over `T: AsyncRead + AsyncWrite + Unpin`), gated + behind the default-on `net` feature (ADR-009; POC #4's gate + shape). +- **Carry the four needs in the same vendor**: the net gate, BIND + (the typestate-driven shape, ADR-007), the `run_udp_proxy_custom` + seam fix, and the `new_reply` publication. +- **Note the vendor point (v1.0.0) in the vendored code**; keep the + MIT license obligations (upstream is MIT; preserve the license + header per file or a bundled notice). +- **`/workspace/fast-socks5` stays the differential-testing + reference** for RFC edge cases (reply-code mapping, domain + addressing, fragmentation) — keep `/workspace/fast-socks5` for + differential testing; never wait on an upstream merge. +- **Offering the net-gate upstream as a courtesy PR stays optional** + (POC #4: it is small enough to offer as-is) — never a wait, never + a dependency. + +## Consequences + +- The crate owns its protocol code in-tree: no upstream release + cadence, no feature waits, and the vendored code co-evolves with + the wrapper (the carried changes are first-class, not patches). +- The cost is maintenance of the vendored subset (~4k LoC upstream; + the vendored subset is the smaller `T`-generic region + codecs). + The vendor's diff vs upstream is deliberate and bounded: the four + carried changes + the license/provenance notes. +- Differential testing against `/workspace/fast-socks5` guards the + RFC edge cases the vendored code must keep passing. + +## References + +- OQ-SK-04 (the resolved posture; the reasoning trail), + OQ-SK-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam) +- POC #4 + POC #1 (`poc-connect-wasm-findings.md` — the measured + gate), POC #5 (`poc-bind-findings.md` — the BIND typestate + driving + the `new_reply` finding) +- ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature + gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0) \ No newline at end of file diff --git a/docs/architecture/decisions/012-target-policy.md b/docs/architecture/decisions/012-target-policy.md new file mode 100644 index 0000000..4f1928e --- /dev/null +++ b/docs/architecture/decisions/012-target-policy.md @@ -0,0 +1,85 @@ +# ADR-012: Target Policy and the Virtual-Subnet Translation Layer + +## Status + +Accepted (2026-09-20, Phase 1; the dialer-refuses shape is +OQ-SK-05's evidence-backed resolution — see ADR-006 — and the +2026-09-14 virtual-subnet proposal is resolved as assembly-layer +policy) + +## Context + +OQ-SK-05's residual Phase 1 questions: (a) does the base crate ship +a per-target allowlist hook, or is target policy entirely the +dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03) — +resolve producer-side, refuse, or pass through unresolved; (c) the +2026-09-14 virtual-subnet proposal (identity-scoped subnets so +proxied resources become IP-addressable — "BND.ADDR ↔ channel id" +translation, a hub exposing proxied resources per identity under +ACL). + +The proposal's key property: the mechanism needs **no wire change** +— DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual +addresses ride ordinary CONNECT/BIND requests; the translation layer +is the dial callback (`dial(target)` matches a virtual subnet +against a per-identity resource table). + +## Decision + +- **No allowlist hook in v1** (ADR-006's resolution, restated as the + target-policy half): the dial callback is the policy point; + per-target refusal is an RFC reply code. The declarative-allowlist + variant remains an additive future extension if a deployment + needs declarative policy; the evidence-backed default is + dialer-refuses. +- **Domain-form targets: pass through unresolved to the dialer.** + Resolution is the egress's/dialer's concern (POC #2 resolved the + local-egress half: "the egress owns resolution" — the ssh `-D` + semantic). The protocol layer never resolves: a wasm producer may + have no resolver at all, and the composed variant's targets ride + the alktunnels direct op (producer-side resolution there, per + alktunnels ADR-007's own posture). fast-socks5's `dns_resolve` + config stays off — it is a kernel-net concern, gated with `net`. +- **The virtual-subnet translation layer is assembly-layer policy, + not a crate feature.** The crate ships no translation-table + helper in v1: the dial callback is the seam, and a deployment + wanting identity-scoped virtual subnets implements the + subnet→resource table there (per-identity scoping = resources + + ACL + target policy; hub exposure = the terminate-and-re-produce + story — no new wire vocabulary). The full-tunnel composition (the + host routes the subnet into the proxy via tun2proxy) is where a + virtual subnet becomes routable from vanilla apps: + `connect(10.x.y.z:p)` is an ordinary CONNECT the dialer + translates. tun2proxy's virtual-network/virtual-DNS support is + the prior art to check *if and when* a deployment builds this — + the crate's obligation is only that the seams support it, which + ADR-004's shape already does. + +## Consequences + +- The security boundary stays exactly two mechanisms: the open + gate (`socks5:open`/`socks5:bind`) and the dial/accept callbacks. + No third policy layer. +- The virtual-subnet endgame is unblocked without crate work: the + translation layer rides the dial callback; if a deployment + materializes with concrete needs, a translation-table helper can + be added additively (assembly-layer code, possibly later + graduated into the crate behind a feature) — the seams don't + change. +- DNS posture is uniform: producer-side resolution everywhere + (local egress resolves; composed egress hands the name to the + direct op which resolves at the tunnel producer). + +## References + +- OQ-SK-05 (the full question set + the proposal's record), + OQ-SK2-07 (the deferred helper question this ADR's resolution + feeds), OQ-SK-01 + (the dial callback), OQ-SK-09 (the multiplexing rejection — the + virtual-subnet idea that survives is recorded in the tracker) +- POC #2 (`poc-udp-associate-findings.md` — the egress-owns-DNS + resolution), ADR-004 (the seams), ADR-006 (the ACL posture), + ADR-008 (the provider defaults) +- alktunnels phase-0-findings OQ-TN-08 (the target-policy + dissolution precedent); tun2proxy (`/workspace/tun2proxy` — the + virtual-network prior art + the endgame composition partner) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md new file mode 100644 index 0000000..a35863b --- /dev/null +++ b/docs/architecture/open-questions.md @@ -0,0 +1,221 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger +OQ-SK-01..09 promoted; new questions continue as OQ-SK2-06..) +--- + +# alksocks — Open Questions + +Centralized tracker. The Phase 0 OQ ledger (`docs/research/ +phase-0.md`, OQ-SK-01..09) is promoted here with statuses current as +of the Phase 1 spec; new Phase 1 questions continue the OQ-SK2 +numbering (SK2 = the architecture-set ledger, distinct from the +Phase 0 ledger's OQ-SK- prefix, which stays historical). Note: the +SK2 numbering is not contiguous from 01 — OQ-SK2-01..05 were +consumed in working drafts before the first committed set; the +sequence starts at the numbers in use. Format per +`docs/sdd_process.md`. + +## Theme 1: Wire format and phases + +### OQ-SK-01: Producer dial policy — what dials the target? + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01) +- **Status**: resolved — [ADR-004](decisions/004-dial-callback-shape.md) + (the shape), [ADR-008](decisions/008-provider-defaults.md) (the + provider set) +- **Priority**: high +- **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback + half closed on the POC evidence (function-not-trait at three + seams — DialFn/EgressFactory/BindAcceptFactory, boxed halves, + ownership rides the handle). The provider-default half: the base + crate ships local-socket providers (`local`) and composed + providers (`composed`, the alktunnels direct op); no implicit + dialer — registration always takes the callbacks explicitly. +- **Cross-references**: OQ-SK-03, OQ-SK-08, ADR-004, ADR-008 + +### OQ-SK-03: UDP ASSOCIATE without binding + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-03) +- **Status**: resolved — [ADR-003](decisions/003-udp-handle-swap.md) +- **Priority**: high +- **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13, + POC #2 validated 2026-09-14). The handle swap: one channel per + association; `{addr, data}` frames in-band (`[len: u16 BE]` over + the vendored address encoding); association lifetime = channel + lifetime; RFC codec at front doors only; sentinel BND.ADDR + in-band. The datagram framing (the one-way door) is now decided + with empirical ground under it (open cost measured ~0.8 ms; LRU + + dedup pinned by test). +- **Cross-references**: OQ-SK-01, ADR-003, ADR-008 + +### OQ-SK-08: BIND — the accept-side command + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-08) +- **Status**: resolved — [ADR-007](decisions/007-bind-in-base.md) +- **Priority**: high +- **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15). + BIND ships in the base crate, phase-model style; the accept + variants: local listener (default, faithful), composed + (alktunnels bind-first listen + forwarded op — vanilla-fidelity + since the graduation), wrapper-aware (in-band default). The + establishment bound covers only the open-op round trip; the + accept wait lives in the teardown-governed pump phase. +- **Cross-references**: OQ-SK-01, ADR-004, ADR-006, ADR-007 + +## Theme 2: Crate architecture + +### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-02) +- **Status**: resolved — [ADR-005](decisions/005-auth-split-by-door.md) +- **Priority**: medium +- **Resolution**: 2026-09-20 (Phase 1; the posture settled + 2026-09-13). "Both," split by the door: producer = identity seam + (in-band NoAuth no-op); front door = RFC 1929 + static + credential→identity config mapping. The door's credential→ + identity mapping shape (the Phase 0 residue): static config in v1; + a pluggable lookup remains an additive extension if a deployment + needs dynamic resolution. +- **Cross-references**: ADR-005, ADR-006 + +### OQ-SK-04: fast-socks5 wasm posture + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-04) +- **Status**: resolved — [ADR-011](decisions/011-inline-vendor.md) +- **Priority**: high +- **Resolution**: 2026-09-20 (Phase 0's final resolution, promoted). + The inline vendor: the `T`-generic subset in-tree behind the + default-on `net` feature; the four carried changes (net gate, + BIND, the udp-bind seam, `new_reply` publication); v1.0.0 noted; + differential testing against `/workspace/fast-socks5`. +- **Cross-references**: ADR-002, ADR-009, ADR-011 + +### OQ-SK-07: POC register + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07) +- **Status**: resolved — the register is closed (process artifact, + not a design question) +- **Priority**: low +- **Resolution**: 2026-09-14 (Phase 0). All POCs resolved: #1, #2, + #4, #5 passed; #3 dropped (transferred to alknet with OQ-SK-06). + Findings live in `docs/research/` (the findings files are the + artifacts); numbering is historical (do not renumber). Full + register: phase-0 §OQ-SK-07. +- **Cross-references**: OQ-SK-06 + +### OQ-SK-09: Channel capacity for proxy workloads (the 256 default) + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-09) +- **Status**: resolved — [ADR-010](decisions/010-channel-capacity.md) +- **Priority**: medium +- **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected + permanently); assembly-layer config is the mechanism; the crate + docs carry the proxy-workload recommendation with the memory + arithmetic; in-channel port multiplexing rejected (the rationale + + the revisit trigger recorded — the additive escape is a new + multiplexed ALPN). +- **Cross-references**: OQ-SK2-08, ADR-010 + +## Theme 3: Policy and composition + +### OQ-SK-05: Target policy and egress scoping + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05) +- **Status**: resolved — [ADR-006](decisions/006-access-control.md) + (the gate posture), [ADR-012](decisions/012-target-policy.md) + (target policy + the virtual-subnet layer) +- **Priority**: high +- **Resolution**: 2026-09-20 (Phase 1). No allowlist hook in v1 (the + dialer-refuses shape); domain targets pass through unresolved + (egress-owned resolution); the virtual-subnet translation layer + is assembly-layer policy over the dial callback (no crate feature + in v1; a helper may be added additively if a deployment + materializes). +- **Cross-references**: OQ-SK2-07, ADR-006, ADR-008, ADR-012 + +### OQ-SK-06: noq client support + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-06) +- **Status**: resolved — transferred to the alknet rewrite (the + 2026-09-14 scope decision) +- **Priority**: low +- **Resolution**: 2026-09-14 (Phase 0). The quinn/noq + `AsyncUdpSocket` client wrapper is alknet's ancestor-shaped + component (ADR-090 + the quinn-proxy POC live there); it neither + produces nor consumes channels. This crate's client story is the + channels-native sessions (POC-validated) — complete without it. +- **Cross-references**: ADR-002 (the base), phase-0 §Scope + +### OQ-SK2-06: The sandboxed-wasm-consumer adapter story + +- **Origin**: [overview.md](overview.md) (the wasm posture's product + input; phase-0 OQ-SK-04's retrospective) +- **Status**: deferred(scope) +- **Door type**: two-way +- **Priority**: low +- **Impacts**: how much wasm surface to exercise in the default + crate (a product/docs input). Blocks nothing in the protocol + design — the wasm-clean invariant (ADR-009) holds regardless. +- **Blocked on**: a concrete sandboxed-adapter use case (a TS/Python + adapter embedding the consumer session). Tracker task id: + `architecture/oq-sk2-06-wasm-adapter-story` + (`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`). +- **Resolution**: Not yet decidable. The use case isn't concrete yet + — the adapter story is a product input whose shape would tell us + whether the wasm consumer needs surface beyond the + channels-native session (e.g. bindings ergonomics). The + architecture decision (wasm-clean default, ADR-009) is already + made and does not depend on this. +- **Cross-references**: ADR-009, ADR-011 + +### OQ-SK2-07: Virtual-subnet translation-layer helper + +- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05's + 2026-09-14 proposal; ADR-012's resolution) +- **Status**: deferred(scope) +- **Door type**: two-way +- **Priority**: low +- **Impacts**: nothing in the crate — the seams already support the + translation layer (the dial callback, ADR-004/ADR-012). This + tracks whether a *translation-table helper* should graduate into + the crate if a deployment materializes with concrete needs. +- **Blocked on**: a concrete deployment wanting identity-scoped + virtual subnets (the tun2proxy virtual-network prior art to check + at that point). Tracker task id: + `architecture/oq-sk2-07-virtual-subnet-helper` + (`tasks/architecture/oq-sk2-07-virtual-subnet-helper.md`). +- **Resolution**: Not yet decidable. The use case isn't concrete — + the mechanism is assembly-layer policy today (ADR-012), and a + crate-level helper would need real deployment requirements to + shape (subnet sizing, per-identity tables, hub exposure). +- **Cross-references**: ADR-004, ADR-006, ADR-012, OQ-SK2-08 + +## Theme 4: Documentation tuning + +### OQ-SK2-08: The recommended proxy-workload cap figure + +- **Origin**: [overview.md](overview.md) §Open Questions; ADR-010's + docs-level residue +- **Status**: open +- **Priority**: low +- **Resolution**: ADR-010 pinned the posture (config, not wire; + documented recommendation with memory arithmetic). The concrete + recommended figure (e.g. "raise per-connection to 4096, per- + identity to 1024" or similar) is a docs tuning note to pin during + implementation when real workload numbers exist. +- **Cross-references**: OQ-SK-09, ADR-010 + +## Deferred / Blocked (index) + +| OQ | Status | Blocked on | Tracker task | +|----|--------|------------|--------------| +| OQ-SK2-06 | deferred(scope) | a concrete sandboxed-adapter use case | `architecture/oq-sk2-06-wasm-adapter-story` | +| OQ-SK2-07 | deferred(scope) | a concrete virtual-subnet deployment | `architecture/oq-sk2-07-virtual-subnet-helper` | + +## References + +- Phase 0 ledger: `docs/research/phase-0.md` §Open Questions + (OQ-SK-01..09 — the full reasoning trails live there) +- The SDD process: `docs/sdd_process.md` (the OQ format; the + deferred-OQ blocker-task half) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..40de4a3 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,240 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger promoted +into ADRs 001-012 and open-questions.md) +--- + +# alksocks — Overview + +A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall +channels: an arbitrary-egress proxy service that **never binds a port +unless explicitly configured to**, carrying the RFC conversation +inside a channels data channel like any other produced resource. The +sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the +alkcall substrate; where alktunnels realizes the `-L`/`-R` forwarding +flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy) — +the one alktunnels explicitly deferred to composition. This document +covers the crate's purpose, the resource model in brief, dependencies, +the ALPN, feature gates, and the module map; component details are in +the sibling documents ([wire.md](wire.md), [producer.md](producer.md), +[consumer.md](consumer.md), [vendor.md](vendor.md)). + +## What + +`alksocks` registers the `alk/socks5` ALPN's open op on alkcall +channels and provides the typed consumer sessions plus the optional +front doors: + +- **The producer half** ([producer.md](producer.md)): the + `channels/socks5/sub` open op (registered via + `ChannelCore::register_openable_with_establisher`, alkcall + ADR-047/049). The establisher is a *session gate* (validates the + opener identity; the SOCKS5 target is unknown at open time); the + open handler runs the RFC 1928 state machine in-stream over the + channel's `BiStream` — the vendored fast-socks5 typestate + ([vendor.md](vendor.md)) drives negotiation, command read, and + replies; the dial/egress/accept seams (ADR-004) supply the I/O + planes; `alkcall::channels::pump_bidi` (ADR-050) is the data plane + for CONNECT and BIND's post-reply#2 stage. +- **The consumer half** ([consumer.md](consumer.md)): a typed + channels-native session that opens `channels/socks5/sub` and speaks + RFC 1928 inside them (`Socks5Stream::use_stream`-shaped; + the associate session and the BIND reply#2 reader). *Wrapper-aware* + means a client that knows the in-band conventions (the sentinel + BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client. + Optionally it + exposes the service locally: the **front door** — a real + `socks5://127.0.0.1:port` endpoint relaying each accepted + connection into a channel (the ssh `-D` local-exposure shape; the + path curl/browser/tun2proxy point at). +- **The optional producer local backend**: the producer may serve + from a real listener (feature-gated; the same door shape in the + other direction — vanilla clients → a locally-served SOCKS5 + resource). + +The defining posture (the crate's reason to exist as a separate +crate): **the protocol never binds**. Kernel binds exist only as the +two explicit, optional, feature-gated assembly shapes above; the +binding decision belongs to the caller. + +## Why + +- The `-D` conclusion, realized as a first-class protocol crate: + alktunnels' Phase 0 settled that `-D` composes at the assembly + layer ("tunnel a socks5 connection"); this crate makes the SOCKS5 + *service* itself a produced, ACL-scoped resource, so both + channels-native consumers and vanilla SOCKS5 clients get the same + service in-band or through the front door. +- The vpn-like endgame composition: user tunnels a produced SOCKS5 + resource to a local port (the front door), points tun2proxy + (`--proxy socks5://user:pass@host:port` natively) at it, and the + whole host's traffic rides the alk* egress. This crate is the + SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the + loop. +- The substrate-agnostic protocol layer: the vendored typestate is + generic over `T: AsyncRead + AsyncWrite + Unpin` — a channels + `BiStream` drops in directly (POC #1), a `TcpStream` drops in at + the local backends. Neither substrate leaks into the protocol + layer (the alktty `TtyBackend` / alktunnels pump-halves + inversion-point precedent). + +## The Resource Model in Brief + +A SOCKS5 channel's life (full detail in [wire.md](wire.md) and +[producer.md](producer.md)): + +1. **Open (the establishment phase).** The consumer opens + `channels/socks5/sub` with trivial params (ADR-001). The + establisher is the session gate — it checks/records the opener + identity and does session-level policy; it never sees a target. + Failure is a typed `channel:open_failed` (alkcall ADR-049); no + phantom channel. +2. **RFC conversation (the control plane, in-stream).** The RFC 1928 + handshake (method negotiation → command → reply) runs in-stream + over the `BiStream`, driven by the vendored typestate. Command + dispatch: CONNECT/ASSOCIATE require `socks5:open`; BIND requires + `socks5:bind` (the command gate, ADR-006). +3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed + halves; `pump_bidi` two-pumps channel ↔ target — raw + pass-through, 0 B overhead. ASSOCIATE: the reply's BND.ADDR is a + pure RFC handle (the sentinel in-band); the datagram stage rides + `[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to + the egress seam. BIND: reply#1 → quiet wait → exactly one accept + (the accept seam) → reply#2 → pass-through (ADR-007). +4. **Teardown.** Per-command: CONNECT/BIND end on channel EOF (the + channels sentinel); ASSOCIATE's association lifetime = channel + lifetime; BIND's quiet wait is `select!`-single-shot with the + client-gone teardown riding the accept future's drop. Ownership + rides the handle structurally (ADR-004). + +Producer and consumer are roles, not sides: both ends of a channels +connection can be both simultaneously (alkcall ADR-022/ADR-037). +RFC 1928's own client/server roles keep their RFC names *inside* the +protocol layer; crate-level vocabulary is producer/consumer. + +## Dependencies + +``` +alksocks (default — wasm-clean) +├── alkcall 0.8.0 (core types: Connection, ProtocolHandler, BiStream, +│ AuthContext, Identity, AccessControl, OwnershipProvider; +│ channels: ChannelCore/ChannelClient, +│ register_openable_with_establisher, pump_bidi, +│ Establishment/ChannelPlan + reply projection, +│ ServingConfig identity seam — CF-005/006) +├── tokio (wasm-clean subset: rt, sync, io-util, macros, time) +├── serde/serde_json (params schema), thiserror (error surface), tracing +└── (no backend deps — sockets live in feature-gated modules) + +alksocks (net feature, default-on) — non-wasm +└── adds: tokio/net, socket2, tokio-stream (the vendored subset's + kernel-net gate; ADR-009) + +alksocks (local feature) — non-wasm by design +└── adds: the first-party local backends + front doors (implies net) + +alksocks (composed feature) +└── adds: alktunnels 0.2.0 (the direct op egress; the bind-first + listen accept; ADR-008's composed providers) — its default + crate is wasm-clean; the composed providers use only the + ungated channels surface (verify with the wasm check) +``` + +Same posture as alktty/alktunnels: the default crate is +protocol-only and wasm-clean; the vendored fast-socks5 subset rides +the `net` gate (ADR-011); substrate I/O is feature-gated +(ADR-009). `fast-socks5` is NOT a Cargo dependency — it is vendored +in-tree (ADR-011). + +## Feature Gates + +| Feature | Contents | Wasm | +|---------|----------|------| +| *(default)* | the vendored `T`-generic subset (codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types — protocol only | yes | +| `net` *(default-on)* | the vendored subset's kernel-net pieces (`tokio/net`, `socket2`): the local-socket default handlers, DNS resolve helper, `Socks5Datagram` kernel socket path | no (wasm builds use `default-features = false`) | +| `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes) — implies `net` | no (build error on wasm, the alktty posture) | +| `composed` | the composed providers over alktunnels 0.2.0: direct-op egress (lazy destination→tunnel table), bind-first listen accept — adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface — verify with the wasm check at implementation) | + +## Module Map + +``` +src/ + lib.rs — re-exports; crate docs + vendor/ — the vendored fast-socks5 T-generic subset (ADR-011; + vendor point v1.0.0 noted in-tree): codecs, typestate, + client use_stream path, carried changes (BIND reply + writer, new_reply publication, the udp-bind seam) + params.rs — the trivial params schema + SOCKS5_OPEN_SCOPE/ + SOCKS5_BIND_SCOPE constants (ADR-001/006) + wire.rs — the ASSOCIATE datagram frame codec, re-implemented + in first-party code shaped on alktunnels' length + codec (ADR-003; framing is wire-layer, applied + exactly once) + producer.rs — the open op spec (scope-gated), the session-gate + establisher, the command-dispatch pump handler + (typestate driving + the three seams), teardown + consumer.rs — the channels-native session types (connect/associate/ + bind reply#2 reader), teardown ownership + door.rs — the front doors (feature `local`): the raw-pipe + bridge + the RFC 1929 cred-bearing door + the + credential→identity mapping (ADR-005); the UDP + front door's RFC codec boundary (ADR-003) + egress.rs — the egress/accept seam types (DialFn/EgressFactory/ + BindAcceptFactory contracts — ADR-004) + local/ — feature `local`: the socket-backed provider + constructors (dial/egress/accept) + composed/ — feature `composed`: the alktunnels-composed + providers (direct-op egress, bind-first accept) + error.rs — SocksError + the typed open-error surface + (alkcall ADR-049 §4) +``` + +Public API surface is `lib.rs` re-exports (AGENTS.md convention 15). +Backend modules are feature-gated and never imported from the +protocol/adapter/client modules. + +## Design Decisions + +All design decisions are documented as ADRs in [decisions/](decisions/). + +| ADR | Decision | Summary | +|-----|----------|---------| +| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}` — the RFC conversation is the data plane | +| [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (vendored); faithful reply-code mapping | +| [003](decisions/003-udp-handle-swap.md) | The handle swap | one channel per association; `{addr, data}` frames in-band; RFC codec at front doors only | +| [004](decisions/004-dial-callback-shape.md) | The dial-callback shape | function-not-trait at three seams (dial/egress/accept); ownership rides the handle | +| [005](decisions/005-auth-split-by-door.md) | Auth: "both," split by the door | producer = identity seam; front door = RFC 1929 + credential→identity mapping (static config) | +| [006](decisions/006-access-control.md) | Access-control posture | `socks5:open` gate + `socks5:bind` command gate; target policy = the dialer | +| [007](decisions/007-bind-in-base.md) | BIND in the base crate | phase model extended (reply#1 → quiet wait → accept → reply#2 → pass-through); additive | +| [008](decisions/008-provider-defaults.md) | Provider defaults | local-socket + composed (direct op) providers, both feature-gated; no implicit dialer | +| [009](decisions/009-feature-gates.md) | Feature gates | `net` (default-on, the vendor's gate), `local`, `composed`; default crate wasm-clean | +| [010](decisions/010-channel-capacity.md) | Channel capacity | no wire change; assembly-layer config + the documented proxy-workload recommendation | +| [011](decisions/011-inline-vendor.md) | The inline vendor | the `T`-generic subset vendored in-tree; the four carried changes; v1.0.0 noted | +| [012](decisions/012-target-policy.md) | Target policy | dialer-refuses; domain targets pass through unresolved; virtual subnets = assembly policy | + +## Open Questions + +Open questions are tracked in [open-questions.md](open-questions.md). +Key questions affecting this document: + +- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope) + — a product input, nothing waits on it) +- **OQ-SK2-08**: recommended proxy-workload cap figure (open — a docs + tuning note; ADR-010 pins the posture) + +## References + +- Phase 0: `docs/research/phase-0.md` (the OQ ledger OQ-SK-01..09 + + the convergence), POC findings (`poc-connect-wasm-findings.md`, + `poc-udp-associate-findings.md`, `poc-bind-findings.md`), + `iroh-socks5-eval.md` +- Upstream ADRs: alkcall ADR-037 (channel ops), ADR-039 (params is + ALPN-specific), ADR-047 (openable ALPNs are operations), ADR-049 + (establishment + reply projection), ADR-050 (`pump_bidi`), ADR-040/ + 041 (channel caps), ADR-051 (hub relay), ledger CF-005/CF-006 + (identity seam); alktunnels ADR-003 (length codec), ADR-007/008 + (direct op; bind-first + forwarded), ADR-006 (ACL posture) +- Siblings: alktty (`/workspace/@alkdev/alktty`), alktunnels + (`/workspace/@alkdev/alktunnels`); reference checkout + `/workspace/fast-socks5` (v1.0.0, MIT, third-party) +- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`) \ No newline at end of file diff --git a/docs/architecture/producer.md b/docs/architecture/producer.md new file mode 100644 index 0000000..600a63c --- /dev/null +++ b/docs/architecture/producer.md @@ -0,0 +1,180 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft) +--- + +# alksocks — The Producer Half + +The producer exposes the SOCKS5 service as a produced, ACL-scoped +resource: it registers the `channels/socks5/sub` open op (ADR-001) +and serves RFC 1928 inside each opened channel. The layering is the +POC #1/#2/#5-validated shape: **establisher = session gate** (the +target is unknown at open time), **open handler = the RFC state +machine + command dispatch** (the vendored typestate driving, ADR-002), +**I/O planes = the three seams** (dial/egress/accept, ADR-004). + +## The Open Op and the Session-Gate Establisher + +Registration uses `ChannelCore::register_openable_with_establisher` +(alkcall ADR-049) with: + +- the op spec `channels/socks5/sub` on the `alk/socks5` ALPN, + `required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006); +- params schema `{type: object}`, `additionalProperties: false` + (trivial — ADR-001); +- the establisher: validates/records the opener identity (the CF-005/ + CF-006 seam — ADR-005) and applies session-level policy. It runs + `Establishment::default()`-shaped (no plan payload) — the dial + happens post-establishment, so there is nothing to thread + establisher→handler (POC #1's wire note). + +Failure semantics: a scope-less identity is `FORBIDDEN` at the +registry ACL gate; establisher refusal is a typed +`channel:open_failed` (`handler_error`-class or the mapped reason) — +never a phantom channel. Session-level refusal is channels-level; +target-level refusal is in-stream RFC (below). + +**Harness wiring requirement** (POC #1, POC #2 re-witnessed): the +accept-side install hook must call `channel0_conn.set_identity +(resolved)` before building the `CallConnection` — the transport +identity does not flow to channel-0 dispatch automatically. A real +mTLS transport resolves the peer here. Without it, scope-gated opens +fail closed with `FORBIDDEN "authentication required"` — correct +behavior, but the wiring requirement is implicit. + +## The Open Handler: RFC Conversation + Command Dispatch + +The open handler runs the vendored typestate over the channel's +`BiStream` (`T`-generic — the POC #1 fit): + +1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` — + NoAuth as a no-op (ADR-005: the producer side is identity-only; + authorization already happened at the open gate). +2. **Command read**: `read_command()` returns `(cmd, target)` — + the dispatch point: + - **CONNECT** → the command gate (`socks5:open` already checked + at the open gate — no extra check) → invoke `DialFn(target)` + (ADR-004). Success: `reply_success` then the pass-through data + plane. Refusal: `reply_error` with the mapped RFC code + (dial-failure → `ConnectionRefused` 0x05; scope-class refusal + → `ConnectionNotAllowed` 0x02) — no phantom data plane. + - **TCP BIND** → **the command gate**: require `socks5:bind` + from the per-call opener identity (ADR-006) — an identity + without it gets `ConnectionNotAllowed` (0x02) while CONNECT + still serves the same identity (POC #5 test 4). Then the BIND + shape (ADR-007, below). + - **UDP ASSOCIATE** → `reply_success(sentinel)` (the + RFC-compatibility handle, ADR-003) then the datagram stage. +3. **Data plane** per command (wire.md §The Data Plane): + - CONNECT: `pump_bidi` inline inside the handler task (the + returned `JoinHandle` tracks the data-plane lifetime — R-02, + alkcall review 007, + `/workspace/@alkdev/alkcall/docs/reviews/ + 007-establishment-follow-ups-review.md`; early return = + teardown-at-birth). + - ASSOCIATE: the `select!`-driven datagram stage; the + `EgressFactory` is called once per association; the stage owns + the egress; stage exit drops it. Framing applied exactly once + by the wire layer; the readiness contract (`Pending` when + nothing is ready) is the seam's API invariant. + - BIND: reply#1 → the quiet wait (`select!`-single-shot; the + `BindAcceptFactory` future owns the listener) → reply#2 → + `pump_bidi` against the accepted halves. + +## The Three Seams (ADR-004) + +| Seam | Called | Returns | Refusal shape | +|------|--------|---------|---------------| +| `DialFn(target)` | per CONNECT, at command read | `TargetHandle {read, write}` (boxed halves, `+ Sync`) | RFC reply code | +| `EgressFactory()` → egress | once per ASSOCIATE; stage owns it | per-association egress (`next_reply` readiness contract) | reply#1 mapped code (pre-reply failure) | +| `BindAcceptFactory` | once per BIND session; quiet wait selects its future | accepted halves + peer | reply#1 (factory) / reply#2 (accept-stage) mapped codes | + +Contracts (POC-pinned, load-bearing): + +- **Ownership rides the handle structurally** — handle payloads own + their channel sessions/sockets; the compiler should enforce it (no + `_` bindings of owning values; the third teardown-at-birth + witness). +- **The egress speaks inner bytes** (`addr + data`); the wire layer + frames once. +- **The accept future owns its resources end-to-end**; the quiet + wait's `select!` drop IS the client-gone teardown. +- **No trait** — the re-evaluation threshold is three real + implementations as trait impls (the alktunnels ADR-004 posture). + +## Scope Gating (ADR-006) + +- `SOCKS5_OPEN_SCOPE = "socks5:open"` — the open op's + `required_scopes`; egress capability (CONNECT + ASSOCIATE). +- `SOCKS5_BIND_SCOPE = "socks5:bind"` — the BIND command gate, + checked at command dispatch from the per-call opener identity; + ingress capability, never implied by `socks5:open`. +- Target policy: the dialer/egress/accept callbacks refuse (RFC + reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever + ACL governs the socks5 resource governs everything reachable + through it, plus the callbacks' policy. +- Optional: consult `OwnershipProvider` for resource ownership — a + registration-time choice. + +## Producer-Side Local Backend (feature `local`, optional) + +The optional shape where the producer serves from a real listener: +a feature-gated backend binds `socks5://127.0.0.1:port` (or any +address) and pipes each accepted TCP connection raw onto a channels +`BiStream` (`copy_bidirectional`, zero protocol translation — POC +#1's curl front door validated the pipe shape). The same door shape +in reverse: this is a *front door for the producer side* — vanilla +clients reach a locally-served SOCKS5 resource through it. The door +surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is the +consumer door's code shape (see [consumer.md](consumer.md)). + +The producer-side local *backend* for the I/O planes (the +local-socket providers — TCP dial, UDP egress, BIND listener) is +ADR-008's variant set, also feature `local`. + +## Wiring (Assembly Layer) + +```rust +let producer = Socks5Producer::builder() + .dial(dial_fn) // DialFn — required for CONNECT + .egress(egress_factory) // EgressFactory — required for ASSOCIATE + .accept(bind_accept_factory) // BindAcceptFactory — required for BIND + .build(); +producer.register(&mut channel_core)?; // register_openable_with_establisher +``` + +Missing callbacks = those commands refuse RFC-faithfully +(`CommandNotSupported`-class), never a panic. Substrate providers +are feature-gated constructors over the same seams (ADR-008/ADR-009): +`local::tcp_dial()`, `local::udp_egress()`, `local::bind_accept()`, +`composed::direct_egress(...)`, `composed::bindfirst_accept(...)`. + +## Design Decisions + +| ADR | Decision | Summary | +|-----|----------|---------| +| [001](decisions/001-alpn-and-open-op.md) | ALPN + op + params | `channels/socks5/sub`, trivial params, session-gate establisher | +| [002](decisions/002-protocol-base.md) | Protocol base | the vendored typestate drives the conversation | +| [004](decisions/004-dial-callback-shape.md) | The seams | DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle | +| [005](decisions/005-auth-split-by-door.md) | Auth | producer = identity seam; in-band NoAuth no-op | +| [006](decisions/006-access-control.md) | ACL | `socks5:open` + `socks5:bind` command gate; dialer-refuses | +| [007](decisions/007-bind-in-base.md) | BIND | the phase model; the accept variants | +| [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated | +| [011](decisions/011-inline-vendor.md) | The vendor | the typestate subset in-tree | + +## Open Questions + +Open questions are tracked in [open-questions.md](open-questions.md). +None currently blocking this document: the seams, gates, and BIND +shape are decided (ADR-004/006/007); OQ-SK2-07 (virtual-subnet +translation) rides the dial callback without touching this surface. + +## References + +- POC #1/#2/#5 findings (the three seams' empirical records) +- Upstream: alkcall ADR-047/049 (ops/establishment), ADR-050 + (`pump_bidi`), ADR-042/051 (hub relay), CF-005/CF-006 (identity + seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op; + bind-first + forwarded) +- [wire.md](wire.md) (the normative wire), + [consumer.md](consumer.md) (the other half) \ No newline at end of file diff --git a/docs/architecture/vendor.md b/docs/architecture/vendor.md new file mode 100644 index 0000000..02929ea --- /dev/null +++ b/docs/architecture/vendor.md @@ -0,0 +1,105 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft) +--- + +# alksocks — The Inline Vendor (fast-socks5 subset) + +The crate owns its RFC 1928 protocol code in-tree: the **inline +vendor** of fast-socks5's `T`-generic subset ([ADR-011](decisions/ +011-inline-vendor.md); OQ-SK-04's resolution). This document is the +vendor's scope, the carried changes, and the maintenance posture. +The protocol base decision is ADR-002; the feature gating is +ADR-009. + +## What is Vendored + +From fast-socks5 **v1.0.0** (MIT, third-party upstream — +`/workspace/fast-socks5` is the read-only reference checkout; the +vendor point is noted in the vendored code): + +| Piece | Why it rides | Gating | +|-------|--------------|--------| +| The server typestate (`Socks5ServerProtocol`, negotiate/auth/command-read/reply) | the producer's conversation driver (ADR-002) | `T`-generic — ungated | +| The auth traits (`AuthMethod`/`AuthMethodSuccessState`) | the negotiation machinery (the crate uses NoAuth in-band; the traits stay for fidelity) | ungated | +| `transfer` (the generic two-pump) | the interception-point shape (the crate's data plane is `pump_bidi`, but `transfer` is the typestate's own seam) | ungated | +| The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `new_reply` (published — carried change #4), `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | ungated | +| The client types: `Socks5Stream::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit) — the first-party associate session in `consumer.rs` was informed by this shape (upstream's `Socks5Datagram` kernel path is the net-gated row) | ungated | +| Kernel-net pieces (the local-socket default handlers, `Socks5Datagram`'s kernel socket path, the DNS resolve helper, the legacy `Socks5Server`/`Incoming`) | upstream surface kept for fidelity where `net` is on | **`net`-gated** | + +Not vendored: the deprecated `Socks5Server`/`Socks5Socket`/`Incoming` +bind-based API as a *default path* (it exists upstream and stays +behind `net`; the crate never routes through it). The vendored subset +is functionally complete without `net` (POC #4: a full RFC 1928 +CONNECT conversation passes `--no-default-features`). + +## The Four Carried Changes (ADR-011) + +The vendor's diff vs upstream v1.0.0 is deliberate and bounded: + +1. **The `net` feature gate** — ~120 `#[cfg]` insertions across 5 + files, exactly POC #4's measured shape + (`poc-connect-wasm-findings.md` §The gating change: server.rs + ~8 insertions; client.rs 87 diff lines (whitespace-inflated); + util/stream.rs, util/target_addr.rs, lib.rs ≤3; Cargo.toml's + `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`). +2. **BIND** — absent upstream (every handler refuses + `Socks5Command::TCPBind`). The crate drives the typestate itself + for the BIND shape (ADR-007); the vendor carries the shape + in-tree (`reply_success`'s `sock_addr` as the reply#1 carrier; + the reply#2 writer using the published `new_reply`). +3. **The `run_udp_proxy_custom` bind seam** — the seam is narrower + than it looks (`udp_bind_random_port` unconditionally; the reply + port derives from that kernel socket). In-tree fix: make the bind + optional / accept a caller-supplied socket. (The channels path + drives the typestate directly regardless; the fix serves the + `net`-gated path's embedders.) +4. **`new_reply` publication** — the reply encoder becomes public + (a `reply(code, addr)`-shaped constructor) so the BIND reply#2 + writer doesn't re-encode the 10/22-byte reply form. + +Plus the provenance notes: the vendor point (v1.0.0) and the license +headers (MIT; preserve per-file headers or a bundled notice — +ADR-011's license obligation). + +## Maintenance Posture + +- **The vendor is first-class code, not a patch stack**: the carried + changes live in-tree and co-evolve with the wrapper; the vendored + subset's RFC fidelity is the crate's own test suite's job. +- **Differential testing**: `/workspace/fast-socks5` stays the + reference for RFC edge cases (reply-code mapping, domain + addressing, fragmentation). The crate's test suite covers the + edge cases the vendored code must keep passing; when in doubt, + diff behavior against the reference checkout. +- **Upstream posture**: fast-socks5 is third-party; nothing waits on + it. Offering the `net` gate upstream as a courtesy PR is optional + (POC #4: small enough to offer as-is); a future upstream release + may be re-based against deliberately, never absorbed silently + (the carried changes would need re-verification). +- **No second codec, ever**: the vendored codecs are the only RFC + wire vocabulary in the crate (ADR-011's rationale; the alknet + anti-prior-art record is in phase-0 §Anti-prior-art). + +## Design Decisions + +| ADR | Decision | Summary | +|-----|----------|---------| +| [002](decisions/002-protocol-base.md) | Protocol base | the explicit typestate API only | +| [009](decisions/009-feature-gates.md) | Feature gates | `net` default-on; wasm builds use `default-features = false` | +| [011](decisions/011-inline-vendor.md) | The inline vendor | the subset in-tree; the four carried changes; v1.0.0 noted | + +## Open Questions + +Open questions are tracked in [open-questions.md](open-questions.md). +None currently blocking this document. + +## References + +- OQ-SK-04 (the resolved posture), OQ-SK-08 (BIND + `new_reply`), + OQ-SK-03 (the bind seam) +- POC #4 (`poc-connect-wasm-findings.md` — the measured gate), POC + #5 (`poc-bind-findings.md` — the BIND driving + the `new_reply` + finding), POC #2 (the typestate-driving shape for ASSOCIATE) +- `/workspace/fast-socks5` (v1.0.0, MIT — the reference checkout); + `docs/research/iroh-socks5-eval.md` (why not the alternative base) \ No newline at end of file diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md new file mode 100644 index 0000000..8a9842a --- /dev/null +++ b/docs/architecture/wire.md @@ -0,0 +1,245 @@ +--- +status: draft +last_updated: 2026-09-20 (Phase 1 initial draft — the POC-validated wire +shapes promoted into the normative spec) +--- + +# alksocks — Wire Format + +The `alk/socks5` wire surface. Two layers: the **open op** (JSON +params on the channels call plane — trivial here, ADR-001) and the +**data plane** (the RFC 1928 conversation + the data streams inside +the channel `BiStream`). The channels layer strips its own 8-byte +header transparently (alknet ADR-093 / alkcall ADR-035); alksocks +owns everything inside the `BiStream` — which is, per the phase +model (ADR-003/ADR-007), the RFC conversation itself plus raw +pass-through, with exactly one framed stage (ASSOCIATE datagrams). + +Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md), +[003](decisions/003-udp-handle-swap.md), [007](decisions/007-bind-in-base.md), +[011](decisions/011-inline-vendor.md)); this document is the normative +WHAT. RFC 1928's own wire rules (method negotiation, command encoding, +reply encoding, the RFC UDP datagram header) are specified by the RFC +itself — this document covers the channels-specific surface and the +phase model's stage boundaries. + +## The Open Op (`channels/socks5/sub`) + +`Sub`-typed operation (alkcall ADR-047): the reply carries +`{ "channel_id": }` once; everything else — the entire RFC 1928 +conversation and the data plane — flows on the channel's `BiStream`, +never in the call response stream. ALPN marker `alk/socks5` (ADR-001). + +### Input (params) + +```json +{} +``` + +- Trivial: the SOCKS5 target is unknown at open time (it arrives + in-stream post-negotiation); the establisher is a session gate, not + a dial (POC #1's structural finding — ADR-004). +- Schema: `{type: object}` with `additionalProperties: false`. Empty + params stay valid forever; a future per-session option would be an + additive schema extension (ADR-001). + +### Reply / errors + +- Success: `{ "channel_id": }` — the establishment phase (the + session gate) succeeded, the open handler is running. +- Failure: a typed `channel:open_failed` call error with + `details: { reason, message }` — `reason ∈ dial_failed` / + `unknown_resource` / `resource_shortage` / `handler_error` / + `timeout` (alkcall ADR-049 §3). The registry's ACL gate runs + before the wrapper (`FORBIDDEN` for a scope-less identity — + POC #1). **A failed open never returns a `channel_id`** — no + phantom channel. Session-level refusal is always channels-level; + *target-level* refusal happens later, in-stream, as an RFC reply + code (ADR-004). +- The scope gate: `required_scopes = ["socks5:open"]` + (`SOCKS5_OPEN_SCOPE`, ADR-006). Caller identity resolves in the + alkcall CF-005 precedence order (payload `auth_token` > + `ServingConfig.identity` > transport identity); the establisher + and open handler receive the per-call opener identity (CF-006). + No extra reply fields in v1 (the session gate has nothing to + project; `with_reply_field` stays available additively). + +## The Phase Model + +RFC 1928 is one command per connection, and control and data never +interleave in either direction. The channel's `BiStream` therefore +has a **phase structure with no mode byte** — the reply itself +synchronizes the switch (POC #2 confirmed post-reply garbage is +refused structurally; the demux-vs-phase trade is resolved by +ADR-003/ADR-007): + +| Command | Phase 1 (control) | Phase 2 (data) | Framing in phase 2 | +|---------|-------------------|----------------|--------------------| +| CONNECT | negotiate → command → reply | raw pass-through | none (0 B) | +| BIND | negotiate → command → reply#1 | quiet wait (no bytes allowed) → accept → reply#2, then pass-through | none after reply#2 | +| UDP ASSOCIATE | negotiate → command → reply (sentinel BND.ADDR) | datagram stage | `[len: u16 BE][addr + data]` per datagram | + +- **Post-reply control bytes are protocol garbage.** CONNECT: the + stream is a pure byte pipe; any non-data interpretation is a + consumer violation. ASSOCIATE: 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`, POC + #2). BIND: any byte in the quiet wait ends the session with no + data plane (POC #5 — the RFC 1928 §3 reading). +- **No in-band control vocabulary.** The alktunnels resolution + ("no in-band control path, ever — the escape hatch is a new ALPN") + holds: no KEEPALIVE, no error frames, no flow-expiry frames. + Errors are EOF-shaped (channels-level) or reply-code-shaped + (in-stream, pre-data-plane). +- **One channel per SOCKS5 session** — no sub-demux, no flow key + (AGENTS.md convention 10; the ASSOCIATE handle swap makes the + channel the relay handle, ADR-003). + +## The Data Plane + +### CONNECT: raw pass-through + +Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_ +bistream, target_read, target_write)` (ADR-050) — the canonical +two-pump shape (alknet ADR-078); each pump shuts down the opposite +sink on completion (half-close semantics fall out); EOF sentinels +are the channels layer's. Zero protocol framing in-band: the RFC +conversation *is* the stream (POC #1: echo round-trips clean; the +front door adds no framing). + +The dial seam (`DialFn`, ADR-004) is invoked at the command-read +interception point with the parsed target. Per-target refusal: +`reply_error` with the mapped RFC code (POC #1: dial refusal → +`ConnectionRefused` 0x05); no phantom data plane. + +### UDP ASSOCIATE: the handle swap + framed datagram stage + +The ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is the sentinel +`127.0.0.1:0` on the in-band path — pure RFC-compatibility surface; +the channel is the relay handle (ADR-003). The datagram stage rides +the same channel: + +- **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the + RFC address encoding (`TargetAddr::to_be_bytes()` — ATYP + addr + + port; the RFC UDP header minus RSV/FRAG). One frame per datagram, + both directions. `len = 0` is a legal empty datagram (never EOF — + the F-2 invariant, shaped on alktunnels ADR-003's codec, + re-implemented in first-party `src/wire.rs`). **The length covers + `addr + data`, not data alone — the joint bound is the + constraint:** a frame is rejected (`Oversize`, at frame time) + whenever `len(addr) + len(data) > 65535`. The UDP payload bound + alone (65507) does *not* fit u16 unconditionally: a 65507-byte + datagram to a 255-character domain is 65507 + 259 (max domain-form + address: ATYP + length byte + 255 + port) = 65766 bytes of frame + payload — a legal RFC 1928 datagram that would overflow u16. The + joint rejection keeps the length field honest: max data is 65276 + for domain-form destinations, 65516 for IPv6, 65528 for IPv4. + Implementations must not use unchecked u16 arithmetic on the + payload bound. +- **RSV/FRAG do not ride the tunnel wire** — fragmentation cannot be + expressed in-band; FRAG ≠ 0 is refused at front doors (the + iroh-socks5 v1 posture). +- **The RFC header codec (`new_udp_header`/`parse_udp_request`) + exists only at vanilla front-door boundaries** (ADR-003; POC #2 + test 8: RFC datagram in, `{addr, data}` frames in-band, RFC + reply-header'd datagrams out, zero payload translation). +- **Association lifetime = channel lifetime.** Channel EOF (the + channels-level sentinel) ends the association; no extra control + vocabulary (POC #2 test 4: a fresh associate succeeds immediately + after a half-close teardown). +- **The stage is `select!`-driven** (channel reads ∥ egress replies), + 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 load-bearing API finding). + **Framing is applied exactly once, by the wire layer:** + `Egress::next_reply` returns *inner* bytes (`addr + data`); the + frame writer adds the single length prefix (the double-framing bug + class POC #2 caught is an API-design invariant). +- **Producer egress** is the `EgressFactory` seam (ADR-004/ADR-008): + local-socket (one `UdpSocket` per association, per-datagram + `send_to`/`recv_from`, no flow table; replies attributed by the + sender's address per the RFC's per-datagram source semantics) or + composed (a lazy destination→direct-op-tunnel table, LRU-evicted; + open cost ~0.8 ms measured, per-target ACL for free). + +### BIND: the phase model extended (ADR-007) + +- Phase 1 ends at **reply#1 carrying `BND.ADDR`** (the accept + variant's real listener address; the sentinel on the wrapper-aware + in-band path). +- **The quiet wait**: no bytes are legal; the `select!` is + single-shot with exactly two exits — the one inbound accept (from + the `BindAcceptFactory` seam, whose future owns the listener), or + the control stream's EOF/garbage/error (the client-gone teardown + riding the accept future's drop). +- **reply#2 carries the peer address** (the real peer on both + non-sentinel variants — local: `accept()`'s peer, byte-exact; + composed: the forwarded op's `peer` param since the bind-first + graduation, alktunnels `docs/research/tunnels-graduation.md`; + sentinel `0.0.0.0:0` wrapper-aware). +- **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape) + between the channel and the accepted halves. +- Failure mapping is RFC-faithful: factory failure (pre-reply#1) → + reply#1 with the mapped code; accept-stage failure (post-reply#1) + → reply#2 with the mapped code, no data plane (POC #5 test 6). + +### Sentinels and lifecycle (all commands) + +- **EOF** is the channels-level zero-length chunk — per-direction + (half-close); written by `pump_bidi`'s shutdown-on-completion or + teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE + association ends on EOF. +- **No mid-stream control frames** in any phase (the phase model's + whole point); pump-phase failures are EOF-shaped (alkcall + ADR-049 §6). +- **BAST:** the ASSOCIATE datagram frame is the one binary framing + beyond pass-through — its BAST document is [bast.md](bast.md). + +## Normative byte diagrams + +CONNECT (post-reply, one direction's view inside the channel +`BiStream`, after the channels layer's 8-byte header per chunk): + +``` + ... +``` + +UDP ASSOCIATE (the datagram stage, one direction's view): + +``` +[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ... + len == 0: an empty datagram (legal payload, NOT EOF) + EOF: the channels-level sentinel, outside the codec +``` + +BIND (the phase sequence, one channel's view): + +``` + | + <- reply#1 (BND.ADDR) | | (accept) + <- reply#2 (peer addr) | ... +``` + +## Open Questions + +Open questions are tracked in [open-questions.md](open-questions.md). +None currently open against this document — the datagram framing +(the one-way door) is decided (ADR-003); OQ-SK2-07 (the +virtual-subnet translation layer) rides the dial callback and does +not touch this surface. + +## References + +- ADR-001 (op/params), ADR-003 (the handle swap + frame), + ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND), + ADR-011 (the vendored codecs) +- POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through, + refusal mapping), `poc-udp-associate-findings.md` (the stage, + readiness contract, framing-once invariant, association lifetime), + `poc-bind-findings.md` (the BIND phases, quiet-wait semantics, + failure mapping) +- Upstream: alkcall ADR-035 (channels pure multiplexing), ADR-037 + (channel open), ADR-049 (establishment/typed errors), ADR-050 + (`pump_bidi`); alktunnels ADR-003 (the length codec + F-2) +- RFC 1928 (the protocol's own wire rules), iroh-socks5 eval + (`docs/research/iroh-socks5-eval.md` — the frame prior art) \ No newline at end of file diff --git a/tasks/architecture/oq-sk2-06-wasm-adapter-story.md b/tasks/architecture/oq-sk2-06-wasm-adapter-story.md new file mode 100644 index 0000000..d2309e5 --- /dev/null +++ b/tasks/architecture/oq-sk2-06-wasm-adapter-story.md @@ -0,0 +1,50 @@ +--- +id: architecture/oq-sk2-06-wasm-adapter-story +name: Track — OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter) +status: pending +depends_on: [] +scope: single +risk: trivial +impact: component +level: research +--- + +## Description + +External-trigger tracker for OQ-SK2-06 (`docs/architecture/ +open-questions.md`): the sandboxed-wasm-consumer adapter story. This +task is NOT actionable work — it tracks whether the external +unblocking condition has arrived. Tagged `[external-trigger, +deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. + +**Unblocking condition**: a concrete sandboxed-adapter use case +exists — a TS/Python (or other) adapter embedding the alksocks +consumer session in a wasm runtime. + +## Work + +When the condition arrives: + +1. Mark this task `completed` (the condition arrived). +2. Transition OQ-SK2-06 from `deferred(scope)` to `open` (or + directly to `resolved` if no architecture decision remains). +3. Decide: how much wasm surface to exercise in the default crate + (bindings ergonomics, which session types to expose, whether any + surface is missing). + +## Verification + +N/A (tracking task — trivial risk, no code). + +## Out of scope + +Any wasm-binding implementation; any protocol change (the +wasm-clean default-crate invariant, ADR-009, holds regardless). + +## Notes + +> Agent fills this during implementation. + +## Summary + +> Agent fills this on completion. \ No newline at end of file diff --git a/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md b/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md new file mode 100644 index 0000000..81133ea --- /dev/null +++ b/tasks/architecture/oq-sk2-07-virtual-subnet-helper.md @@ -0,0 +1,53 @@ +--- +id: architecture/oq-sk2-07-virtual-subnet-helper +name: Track — OQ-SK2-07 unblock condition (virtual-subnet deployment) +status: pending +depends_on: [] +scope: single +risk: trivial +impact: component +level: research +--- + +## Description + +External-trigger tracker for OQ-SK2-07 (`docs/architecture/ +open-questions.md`): the virtual-subnet translation-layer helper. +This task is NOT actionable work — it tracks whether the external +unblocking condition has arrived. Tagged `[external-trigger, +deferred-oq]` per `docs/sdd_process.md` §Deferred OQs. + +**Unblocking condition**: a concrete deployment wants identity-scoped +virtual subnets (proxied resources IP-addressable per identity) — +the shape ADR-012 resolved as assembly-layer policy over the dial +callback. + +## Work + +When the condition arrives: + +1. Mark this task `completed` (the condition arrived). +2. Transition OQ-SK2-07 from `deferred(scope)` to `open` (or + directly to `resolved` if assembly-layer policy still suffices). +3. Decide: does a translation-table helper graduate into the crate + (subnet sizing, per-identity tables, hub exposure)? Check + tun2proxy's virtual-network/virtual-DNS support as prior art + (`/workspace/tun2proxy`). + +## Verification + +N/A (tracking task — trivial risk, no code). + +## Out of scope + +Any crate-level implementation of the translation layer before the +condition arrives (ADR-012: the dial callback is the seam; the +crate's obligation is only that the seams support it). + +## Notes + +> Agent fills this during implementation. + +## Summary + +> Agent fills this on completion. \ No newline at end of file