docs: three-component split; fork-first (vendor-and-vet) posture; consumer-side bind

- phase-0 vision: components split — (1) producer half (expose the
  SOCKS5 service over channels), (2) consumer half (consume it,
  optionally bind it locally — the ssh -D front door + tun2proxy
  path), (3) standalone SOCKS client wrapper for quinn/noq that must
  work with any RFC 1928 server (iroh relay/peer IP-leak motivation,
  alknet ADR-090 generalized); the earlier docs conflated the
  consumer-side bind with the producer-side local backend
- principle 6: the vpn-like endgame — tunnel a produced SOCKS5
  resource to a local port, point tun2proxy (--proxy socks5://
  natively) at it; verified tun2proxy src/args.rs
- OQ-SK-04 resolved as fork-first: 'fork' = vendor-and-vet inline in
  the crate (minimal diff, feature-gate native-net), PR upstream as
  courtesy, never wait on approval; wasm-drop (alkhttp precedent) is
  the escape hatch, not the plan
- AGENTS.md conventions 4/9/17 aligned (fork-first, binds on either
  side, wasm posture)
This commit is contained in:
2026-09-12 10:21:29 +00:00
parent 216a5e965e
commit 766e489f99
2 changed files with 136 additions and 52 deletions
+29 -20
View File
@@ -90,10 +90,11 @@ implementation agents.
(`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features (`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features
= ["full"]`** — and keep socket/platform I/O feature-gated. Note: = ["full"]`** — and keep socket/platform I/O feature-gated. Note:
`fast-socks5` itself uses `tokio::net` and `socket2` unconditionally; `fast-socks5` itself uses `tokio::net` and `socket2` unconditionally;
whether it compiles wasm-clean, needs a fork, or the wrapper the resolved posture (OQ-SK-04) is fork-first: vendor the needed
reimplements the SOCKS5 state machine over `tokio::io` generics is an subset and feature-gate native-net pieces (the alkhttp crate shows a
open question (see `docs/research/phase-0.md` OQ-SK-04) — do not native-only crate is possible, but wasm-clean remains the preferred
assume either way until it is resolved. posture here). Run the wasm check whenever a non-backend module
changes.
5. **Wire format is stable** — the SOCKS5 protocol itself is RFC 1928 5. **Wire format is stable** — the SOCKS5 protocol itself is RFC 1928
(fixed); this crate's wire surface is the ALPN + the channel open-op (fixed); this crate's wire surface is the ALPN + the channel open-op
@@ -145,13 +146,17 @@ implementation agents.
The SOCKS5 server must run without binding any port: the RFC 1928 The SOCKS5 server must run without binding any port: the RFC 1928
conversation is carried inside a channels data channel, and target conversation is carried inside a channels data channel, and target
dials happen on the producing side (or hop further through dials happen on the producing side (or hop further through
alktunnels/alksocks). A local bind (a genuine `socks5://host:port` alktunnels/alksocks). Local binds exist only as explicit, optional
endpoint) is an optional assembly-layer/feature-gated capability. assembly-layer shapes on either side: the producer may serve from a
UDP ASSOCIATE is the hard case: RFC 1928 replies with a UDP relay real listener (a feature-gated local backend), and the consumer may
address and the client sends datagrams to it, which seems to demand a expose the resource on a local port (the ssh `-D` front door that
bind — how the relay address is virtualized (or the associate shape vanilla clients — curl, browsers, tun2proxy — point at). The
adapted) is a Phase 0 question (OQ-SK-03). Binding decisions belong protocol itself never binds. UDP ASSOCIATE is the hard case: RFC
to the caller (assembly layer), never the protocol crate. 1928 replies with a UDP relay address and the client sends
datagrams to it, which seems to demand a bind — how the relay
address is virtualized (or the associate shape adapted) is a Phase 0
question (OQ-SK-03). Binding decisions belong to the caller
(assembly layer), never the protocol crate.
10. **Backpressure and limits are inherited, not redefined** — bounded 10. **Backpressure and limits are inherited, not redefined** — bounded
per-channel buffers, the 256-channel per-connection cap, monotonic per-channel buffers, the 256-channel per-connection cap, monotonic
@@ -222,15 +227,19 @@ implementation agents.
One ALPN per protocol. The ALPN string is wire-stable once One ALPN per protocol. The ALPN string is wire-stable once
published — decide via ADR before the first consumer. published — decide via ADR before the first consumer.
17. **Upstream is ours — make asks early.** We own `fast-socks5` 17. **Upstream is ours — fork-first, PR as courtesy.** We own
(`/workspace/fast-socks5`, v1.0.0, MIT) and alkcall. If the wrapper `fast-socks5` (`/workspace/fast-socks5`, v1.0.0, MIT) and alkcall.
needs a change upstream (a typestate hook, a generic split, a new For changes this crate needs upstream, the preferred posture is:
event), file it and land it there rather than working around it make the change as a minimal fork and use it — offer it upstream
locally — the alktunnels precedent (the E-01/E-02 sweep, filed from as a PR and merge upstream if they want it, but never wait on
its Phase 0 and landed in alkcall 0.5.0 within a day). Keep approval (see `docs/research/phase-0.md` OQ-SK-04's resolved
`/workspace/fast-socks5` as the reference checkout; if upstream posture). For alkcall (the actively co-developed substrate) the
diverges from what we publish, note the fork point in the research alk* precedent still applies: file asks early and land them there
docs. rather than working around them locally — the alktunnels precedent
(the E-01/E-02 sweep, filed from its Phase 0 and landed in alkcall
0.5.0 within a day). Keep `/workspace/fast-socks5` as the reference
checkout; if the vendored subset diverges from upstream, note the
fork point in the research docs.
## Verification Commands ## Verification Commands
+107 -32
View File
@@ -27,15 +27,43 @@ port unless explicitly configured to, wrapping `fast-socks5` so the RFC
conversation rides inside a channels data channel like any other produced conversation rides inside a channels data channel like any other produced
resource. resource.
**The three components (2026-09-12 clarification — they serve different
purposes and were at risk of being conflated):**
1. **Producer half** — a side exposes the SOCKS5 service as a produced,
ACL-scoped resource (registers openable channels; the open handler
runs the RFC state machine; target dials happen there or further
downstream). This is the `-D` service over channels.
2. **Consumer half** — a side opens SOCKS5 channels against a produced
service and speaks RFC 1928 inside them. *Optionally* it may expose
the service locally: bind a real `socks5://127.0.0.1:port` endpoint
and relay each accepted connection into a channel (the ssh `-D`
local-exposure shape, and the path tun2proxy/curl/browser point at).
3. **SOCKS client wrapper for QUIC runtimes**`AsyncUdpSocket`
implementations (quinn and noq) so downstream clients (alknet) can
route QUIC through *any* third-party SOCKS5 server — not just one
produced by this crate. The motivating case is iroh's privacy
posture: iroh relays see the real IP (intended for relay-assisted
p2p), and a client that doesn't want to leak it needs a SOCKS hop
ahead of the QUIC dial. This component is a plain client library
(the alknet ADR-090 descendant); it neither produces nor consumes
channels.
Components 1+2 form the producer/consumer protocol pair (channels in,
RFC 1928 inside); component 3 is standalone and composes with any
RFC 1928 server. Keep them structurally separate in the crate layout.
Guiding principles, inherited from the alk* family: Guiding principles, inherited from the alk* family:
1. **"ALPN as a service," not "a server."** The SOCKS5 service is a 1. **"ALPN as a service," not "a server."** The SOCKS5 service is a
produced, ACL-scoped resource on a channels connection — the same produced, ACL-scoped resource on a channels connection — the same
shape as an alktty terminal or an alktunnels TCP tunnel. A genuine shape as an alktty terminal or an alktunnels TCP tunnel. Kernel
`socks5://host:port` kernel-socket listener is one optional binds exist only as explicit, optional assembly-layer shapes on
assembly-layer shape (a feature-gated local backend), never the *either side* — the producer may serve from a real listener (a
protocol's home. This inverts the usual SOCKS5 deployment posture and feature-gated local backend, component 1's optional shape), and the
is the crate's defining requirement. consumer may expose the resource on a local port (component 2's
optional shape, the ssh `-D` front door). The protocol itself never
binds; the binding decision belongs to the caller.
2. **The `-D` conclusion, realized.** alktunnels' Phase 0 settled that 2. **The `-D` conclusion, realized.** alktunnels' Phase 0 settled that
`-D` composes at the assembly layer: "`-D` is just tunnel a socks5 `-D` composes at the assembly layer: "`-D` is just tunnel a socks5
connection"; target selection lives in the SOCKS5 protocol at the connection"; target selection lives in the SOCKS5 protocol at the
@@ -47,7 +75,7 @@ Guiding principles, inherited from the alk* family:
is generic over `T: AsyncRead + AsyncWrite + Unpin`, and its is generic over `T: AsyncRead + AsyncWrite + Unpin`, and its
interception points (`run_tcp_proxy`, `run_udp_proxy_custom`, interception points (`run_tcp_proxy`, `run_udp_proxy_custom`,
`transfer`) accept any such `T`. The channels adapter feeds the state `transfer`) accept any such `T`. The channels adapter feeds the state
machine a `BiStream`; the local backend feeds it a `TcpStream`. The machine a `BiStream`; a local backend feeds it a `TcpStream`. The
wrapper must not leak either substrate into the protocol layer wrapper must not leak either substrate into the protocol layer
(alktty `TtyBackend` / alktunnels pump-halves inversion-point (alktty `TtyBackend` / alktunnels pump-halves inversion-point
precedent). precedent).
@@ -63,6 +91,16 @@ Guiding principles, inherited from the alk* family:
QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the
server side (the `-D` capability alktunnels deferred). This crate server side (the `-D` capability alktunnels deferred). This crate
rehomes both halves behind one protocol crate and improves them. rehomes both halves behind one protocol crate and improves them.
6. **The vpn-like endgame composition.** A SOCKS5 server + tun2proxy is
the last step (ish) of "vpn-like without actually being a vpn": a
user tunnels a produced SOCKS5 resource to a local port (component
2's optional shape), then points tun2proxy (`/workspace/tun2proxy`
— it takes `--proxy socks5://user:pass@host:port` natively,
`src/args.rs`) at it, and the whole host's traffic rides the alk*
egress. This crate is the SOCKS5 leg of that composition; alktunnels
carries the tunnel; tun2proxy closes the loop. (The udpgw framing
alktunnels' UDP format is based on is the same prior art
OQ-SK-03's datagram codec draws on.)
## What is already settled ## What is already settled
@@ -428,10 +466,11 @@ Option A upstream if confirmed. This OQ gates the wasm verification
command in AGENTS.md (the expected-failure note is already written command in AGENTS.md (the expected-failure note is already written
there). there).
**Fork vs reimplement vs wrap — collapses onto this OQ (2026-09-12 **Fork vs reimplement vs wrap — resolved as fork-first (2026-09-12
discussion).** The wrap surface was re-checked and **no structural fork discussion, supersedes the earlier upstream-ask-first weighting).** The
is needed** for the channels path: CONNECT rides the interception points wrap surface was re-checked and **no structural fork is needed** for the
as-is — `read_command` → own dial → `reply_success``transfer` (the channels path: CONNECT rides the interception points as-is —
`read_command` → own dial → `reply_success``transfer` (the
`examples/router.rs` shape), with the producer's `BiStream` as `T` on `examples/router.rs` shape), with the producer's `BiStream` as `T` on
both sides of `transfer`. UDP is ours either way: `run_udp_proxy_custom` both sides of `transfer`. UDP is ours either way: `run_udp_proxy_custom`
takes a custom `transfer` closure for the relay half, `reply_success` takes a custom `transfer` closure for the relay half, `reply_success`
@@ -441,16 +480,33 @@ The one hardwired piece — `run_udp_proxy`'s peer relay socket
(`udp_bind_random_port`, socket2-based, called inside the default (`udp_bind_random_port`, socket2-based, called inside the default
handler before `run_udp_proxy_custom` even runs) — is in the *default* handler before `run_udp_proxy_custom` even runs) — is in the *default*
handler, not the protocol: the wrapper's custom closure never needs it. handler, not the protocol: the wrapper's custom closure never needs it.
So the fork-vs-reimplement decision is not about the channels surface
at all; it is driven by the wasm question above: **wrap (with the **Decision: fork-first, "fork" = inline in this crate (vendor-and-vet).**
upstream `net` feature-gate ask) if the gating lands cleanly; Do the feature gating as a minimal, minimal-diff fork and use it —
reimplement if the gating turns out structural.** The reimplementation offering it upstream as a PR (merge if they want it; we carry the fork
is genuinely small — RFC 1928 over `tokio::io` generics is the regardless). Rationale: depending on someone else's approval cadence is
alktunnels-codec scale of code — and fast-socks5 would remain the the worst part of the upstream-ask path; the long-term cost of
differential-test oracle for RFC edge cases (reply-code mapping, maintaining a minimal fork of a well-written lib trends toward zero
domain addressing, fragmentation) either way. A full fork (divergent (modern tooling/AI assistance makes small-diff rebase-and-review cheap);
copy we publish) is the least attractive branch: it duplicates the and the changes needed here are genuinely small (feature-gate
maintenance without buying anything the two options above don't. `tokio::net`/`socket2` behind a default-on `net` feature, per the
alktty `local` pattern). Concretely this likely means **vendoring the
relevant subset into this crate** (the codecs, typestate machinery,
client `use_stream` path — the parts that are already `T`-generic) and
gating the native-net pieces behind the crate's own `local` feature,
rather than publishing a divergent crate. fast-socks5 remains the
reference checkout for differential testing of RFC edge cases
(reply-code mapping, domain addressing, fragmentation) and the PR
source. If upstream accepts the feature-gating PR, the fork shrinks to
a plain dependency; if not, we carry it — either way this crate's
timeline is not hostage to approval. What is *not* off the table:
dropping wasm outright is still available (Option C) as the
alkhttp precedent shows a crate can ship native-only — but it is not
preferred; the fork makes the wasm-clean default achievable without
anyone's approval. (The user also flagged the alternative of simply
not worrying about wasm for this crate and riding the `router.rs`
native path; that remains viable if the fork's gating turns out more
invasive than expected — an escape hatch, not the plan.)
### OQ-SK-05: Target policy and egress scoping ### OQ-SK-05: Target policy and egress scoping
@@ -476,12 +532,23 @@ mechanism. Residual questions for Phase 1:
### OQ-SK-06: noq client support (and the quinn/noq shape split) ### OQ-SK-06: noq client support (and the quinn/noq shape split)
The client half's UDP story is "implement the QUIC runtime's abstract **Scope clarification (2026-09-12):** the SOCKS client wrapper is
socket trait over a SOCKS5 UDP association." quinn 0.11 is proven component 3 of the three-component split (§Vision) — a standalone
(quinn-proxy POC, ADR-090). noq (iroh's fork, v1.2.0) has the same client library for downstream users (alknet) that must work with *any*
extension point (`AsyncUdpSocket`, `Endpoint::new_with_abstract_socket`) RFC 1928 server, not just one this crate produces. The motivating case
but the trait shape changed: `create_sender() -> Pin<Box<dyn UdpSender>>` is iroh's privacy posture: iroh relays (and the peer) see the client's
replaces `create_io_poller` + `try_send`, and the constructor takes real IP — intended for relay-assisted p2p, but a client that doesn't
want to leak its IP needs a SOCKS hop ahead of the QUIC dial. This is
the alknet ADR-090 use case generalized; it neither produces nor
consumes channels.
The client wrapper's UDP story is "implement the QUIC runtime's
abstract socket trait over a SOCKS5 UDP association." quinn 0.11 is
proven (quinn-proxy POC, ADR-090). noq (iroh's fork, v1.2.0) has the
same extension point (`AsyncUdpSocket`,
`Endpoint::new_with_abstract_socket`) but the trait shape changed:
`create_sender() -> Pin<Box<dyn UdpSender>>` replaces
`create_io_poller` + `try_send`, and the constructor takes
`Box<dyn AsyncUdpSocket>` instead of `Arc<dyn AsyncUdpSocket>`. `Box<dyn AsyncUdpSocket>` instead of `Arc<dyn AsyncUdpSocket>`.
Questions: Questions:
@@ -577,14 +644,22 @@ Candidate reading for the research specialist (to be expanded):
- tun2proxy — `/workspace/tun2proxy` `src/udpgw.rs` and its SOCKS5 - tun2proxy — `/workspace/tun2proxy` `src/udpgw.rs` and its SOCKS5
files (`socks.rs`, `proxy_handler.rs`): UDP-over-stream framing and files (`socks.rs`, `proxy_handler.rs`): UDP-over-stream framing and
flow-table prior art (analyzed in alktunnels phase-0-findings; the flow-table prior art (analyzed in alktunnels phase-0-findings; the
SOCKS5-specific parts feed OQ-SK-03). SOCKS5-specific parts feed OQ-SK-03). Also the endgame composition
partner: it takes `--proxy socks5://...` upstream natively
(`src/args.rs`) — the vpn-like endgame is tunnel + local bind +
tun2proxy (§Vision, principle 6).
- alkhttp — `/workspace/@alkdev/alkhttp`: the native-only precedent in
the suite (axum/reqwest/hyper, no wasm target) — cited in OQ-SK-04
as the "wasm-clean is preferred, not mandatory" escape hatch.
- iroh — `/workspace/iroh` `iroh/Cargo.toml` (the noq dependency - iroh — `/workspace/iroh` `iroh/Cargo.toml` (the noq dependency
posture: `noq = "1.2.0"` from the n0 workspace) — context for posture: `noq = "1.2.0"` from the n0 workspace) — context for
OQ-SK-06's dependency question. OQ-SK-06's dependency question; also the privacy motivation for the
client wrapper (relays/peers see the real IP).
## Convergence checklist (what Phase 0 must produce) ## Convergence checklist (what Phase 0 must produce)
- [ ] Vision + guiding principles captured (this doc, §Vision) - [ ] Vision + guiding principles captured (this doc, §Vision
including the three-component split)
- [ ] Prior-art pass complete: fast-socks5 surface verified (§Prior - [ ] Prior-art pass complete: fast-socks5 surface verified (§Prior
art), alktunnels/alknet/noq lineage mapped, anti-prior-art list art), alktunnels/alknet/noq lineage mapped, anti-prior-art list
written written
@@ -596,9 +671,9 @@ Candidate reading for the research specialist (to be expanded):
the phase-model-vs-alktty-demux analysis); resolve via research the phase-model-vs-alktty-demux analysis); resolve via research
+ POC #2, ADR before the first consumer + POC #2, ADR before the first consumer
- [ ] OQ-SK-04 (fast-socks5 wasm) — verify empirically (POC #4); - [ ] OQ-SK-04 (fast-socks5 wasm) — verify empirically (POC #4);
the fork-vs-reimplement-vs-wrap decision collapses onto this resolved posture: fork-first (vendor-and-vet, PR as courtesy) —
OQ; likely an upstream feature-gating ask; file early per carry the minimal fork regardless of upstream's decision;
AGENTS.md convention 17 wasm-drop (alkhttp precedent) is the escape hatch, not the plan
- [ ] OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision; - [ ] OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision;
scope-gate convention pinned in Phase 1 scope-gate convention pinned in Phase 1
- [ ] OQ-SK-06 (noq client) — research pass (trait-shape comparison, - [ ] OQ-SK-06 (noq client) — research pass (trait-shape comparison,