docs: Phase 1 architecture — specs, ADRs 001-012, open-questions tracker
- docs/architecture/: README index, overview, wire, producer, consumer, vendor, bast specs + open-questions.md (the OQ-SK-01..09 ledger promoted; new questions as OQ-SK2-06..) - ADRs 001-012: ALPN/op/params, protocol base, the handle swap, the dial-callback shape, auth split by the door, access control (open gate + BIND command gate), BIND in base, provider defaults, feature gates, channel capacity, the inline vendor, target policy - deferred OQs (SK2-06, SK2-07) with tracker tasks in tasks/architecture/ per the sdd_process deferred-OQ protocol - review pass applied: frame-length joint bound (65766 corner), accept_no_auth vs skip_auth corrected against fast-socks5 source, composed-feature wasm posture, cross-reference fixes Verified: cross-references (all ADR/OQ refs resolve), doc sizes under split threshold, Phase 0 fidelity spot-checks
This commit is contained in:
1 parent
3044abe296
commit
9587670138
22 files changed
+2488
No files matched your search
@@ -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`)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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": <u32>}`** — 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
|
||||
@@ -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<T, states::*>` 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<T, …>` (negotiate/finish_auth/
|
||||
read_command/reply_success/reply_error), the client side
|
||||
`Socks5Stream<S>::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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
`<alpn-family>:<capability>` (`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)
|
||||
@@ -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`)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`)
|
||||
@@ -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)
|
||||
@@ -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<T, states::*>`, negotiate/auth/command-read/reply) | the producer's conversation driver (ADR-002) | `T`-generic — ungated |
|
||||
| The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | 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<S>::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)
|
||||
@@ -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": <u32> }` 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": <u32> }` — 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):
|
||||
|
||||
```
|
||||
<payload bytes> ... <EOF sentinel: channels-level length=0 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):
|
||||
|
||||
```
|
||||
<negotiation> <BIND cmd> |
|
||||
<- reply#1 (BND.ADDR) | <quiet: any byte = garbage> | (accept)
|
||||
<- reply#2 (peer addr) | <pass-through bytes> ... <EOF sentinel>
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user