Files
alksocks/docs/research/phase-0.md
T
glm-5.3-flash 601afa3922 docs: BIND use case found (OQ-SK-08, POC #5) + capacity OQ-SK-09
Post-POC #2 findings review: a real BIND use case and mechanism
surfaced, plus a channel-capacity analysis for proxy workloads.

- OQ-SK-08: fast-socks5 parses but refuses TCPBind — the wrapper owns
  the whole shape (zero upstream ask). Phase-model extension: reply#1
  (BND.ADDR) -> quiet wait -> one inbound accept -> reply#2 (peer) ->
  raw pass-through; iroh-socks5's channels-native BIND is convergent
  precedent. Accept variants mirror OQ-SK-03's egress variants:
  feature-gated local listener (faithful RFC) vs composed alktunnels
  listen-tunnel (needs a listen-addr-inspection ask) vs wrapper-aware
  only. Security note: BIND is inbound egress — SOCKS5_OPEN_SCOPE must
  distinguish it from CONNECT/ASSOCIATE.
- OQ-SK-09: 256-channel default vs proxy fan-out; wire change
  rejected, assembly-layer config + documented proxy-workload
  recommendation is the mechanism; in-channel port multiplexing
  ([port:u16] per chunk) considered and rejected (re-opens OQ-SK-03's
  demux-vs-phase trade, breaks 0-B pass-through, AGENTS.md #10).
- OQ-SK-05 addendum: virtual address space / identity-scoped subnets
  adoptable with no wire change — DST.ADDR/BND.ADDR translation via
  the dial callback; hub exposure via terminate-and-re-produce.
- POC #5 (BIND, both accept variants, vanilla front-door test) added
  to OQ-SK-07; checklist + convergence updated; upstream-asks list
  pinned (fast-socks5: net gate + UDP bind seam; alktunnels:
  listen-addr inspection).

Verification: docs-only change (phase-0.md).
2026-09-15 03:28:15 +00:00

1216 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-09-14 (post-POC #2 review: OQ-SK-08 BIND, OQ-SK-09
capacity, OQ-SK-05 virtual-subnet addendum, POC #5)
---
# alksocks — Phase 0 (Exploration)
This document captures Phase 0 (Exploration) for the `alksocks` crate:
vision, guiding principles, prior art, open questions (OQ-SK-01..NN), and
POC candidates. Phase 0's objective per `docs/sdd_process.md`: *capture
vision and guiding principles; research options; validate approaches;
converge on a recommended approach.* It is the input to Phase 1
(Architecture), where the Architect will produce `docs/architecture/`
specs, ADRs, and the open-questions tracker.
Drafted 2026-09-12, emerging from the initial setup discussion. The crate
is 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.
## Vision and guiding principles
**One sentence:** 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, wrapping `fast-socks5` so the RFC
conversation rides inside a channels data channel like any other produced
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.
**Scope resolution (2026-09-14, post-POC): component 3 moves to the
alknet rewrite.** The QUIC-runtime client wrapper's natural home is
alknet — it is alknet's own ancestor (ADR-090 + the quinn-proxy POC
live there), it neither produces nor consumes channels, and the
planned alknet rewrite is where downstream clients (iroh privacy
posture) will actually consume it. That pins alksocks' scope to the
producer/consumer pair only: **socks + channels.** Consequence: the
OQ-SK-06 noq research (and its would-be POC #3) transfers to alknet's
Phase 0; the quinn 0.11 variant is already proven there
(quinn-proxy POC). This crate's client-side story reduces to the
channels-native consumer session (`Socks5Stream::use_stream` over a
`BiStream` — POC #1) plus the wrapper-aware associate session (POC
#2) and the optional front doors.
Guiding principles, inherited from the alk* family:
1. **"ALPN as a service," not "a server."** The SOCKS5 service is a
produced, ACL-scoped resource on a channels connection — the same
shape as an alktty terminal or an alktunnels TCP tunnel. Kernel
binds exist only as explicit, optional assembly-layer shapes on
*either side* — the producer may serve from a real listener (a
feature-gated local backend, component 1's optional shape), and the
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
`-D` composes at the assembly layer: "`-D` is just tunnel a socks5
connection"; target selection lives in the SOCKS5 protocol at the
producing side, governed by the same op-level ACL as the resource.
This crate is that conclusion's realization as a first-class protocol
crate, not an assembly-layer afterthought.
3. **Wrap `fast-socks5`, preserve its genericity.** `fast-socks5`'s
explicit typestate server API (`Socks5ServerProtocol<T, states::*>`)
is generic over `T: AsyncRead + AsyncWrite + Unpin`, and its
interception points (`run_tcp_proxy`, `run_udp_proxy_custom`,
`transfer`) accept any such `T`. The channels adapter feeds the state
machine a `BiStream`; a local backend feeds it a `TcpStream`. The
wrapper must not leak either substrate into the protocol layer
(alktty `TtyBackend` / alktunnels pump-halves inversion-point
precedent).
4. **Producer/consumer vocabulary.** Both sides of a channels connection
can initiate; a producer registers openable SOCKS5 channels
(`ChannelCore::register_openable`), a consumer opens them and speaks
RFC 1928 inside. RFC 1928's own client/server roles keep their RFC
names *inside* the protocol layer, but crate-level docs and API use
producer/consumer. Avoid "SOCKS5 server" as a crate-level noun.
5. **Extract-and-improve from alknet.** alknet's SOCKS5 story is the
direct ancestor: the client side (`alknet-client/src/socks5.rs`,
ADR-090 — `Socks5UdpSocket` implements `quinn::AsyncUdpSocket` so
QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the
server side (the `-D` capability alktunnels deferred). This crate
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
The foundation is POC-validated and ADR-pinned upstream; this crate does
not start from zero. It inherits:
- **The channels data path** — demux→Connection→handler→mux, validated
by the alknet-channels POC and production alktty/alktunnels. The SOCKS5
payload is raw bytes inside a channels data channel's `BiStream`;
channels strips its 8-byte header transparently (alknet ADR-093 /
alkcall ADR-035). SOCKS5 CONNECT is one `BiStream` per session — the
RFC conversation *is* the stream; no sub-demux, no framing, no flow
key (one channel per SOCKS5 session).
- **The two-pump data plane** — the CONNECT proxy loop is the canonical
two-pump shape; `alkcall::channels::pump_bidi` (ADR-050) is pinned
upstream and alktunnels-validated. Use it; do not hand-roll.
- **The establishment story** — session opens use
`register_openable_with_establisher` (alkcall ADR-049 + amendment 2):
the establisher runs as an awaited, bounded establishment phase; a
refused session is a typed `channel:open_failed` call error
(`reason ∈ dial_failed / unknown_resource / resource_shortage /
handler_error / timeout`), never a phantom channel. The negotiate/
auth half of the RFC conversation still runs in-stream over the
`BiStream` — establishment covers only the producer's accept policy,
not the RFC handshake.
- **The producer/consumer model** — producer registers openable channels
(authorization for free via `AccessControl`); consumer opens them via
`ChannelClient` (alkcall ADR-037, ADR-043). Connection direction is
independent of service direction.
- **The relay/hub story** — a SOCKS5 resource traverses alkcall hub
relays like any produced resource; the hub's terminate-and-re-produce
proxy (alktunnels' refinement of alkcall ADR-042) applies per hop, and
a SOCKS5 resource is exactly the "further downstream" shape alktunnels
described. No socks5-specific relay work.
- **The identity seam** — alkcall 0.7.0's CF-005/CF-006: the per-call
opener identity arrives on the open-op hooks. Auth mapping (identity →
SOCKS5 credentials/ACL, if any) happens there, not via in-band SOCKS
auth, unless RFC-facing username/password is needed for vanilla SOCKS5
clients (OQ-SK-02).
- **The backend inversion point pattern** — substrate-specific types
confined to feature-gated backend modules, injected at the assembly
layer (alktty `TtyBackend` / alktunnels no-trait precedent).
- **The wasm-clean default crate** — protocol-only code should compile
to `wasm32-unknown-unknown` (alktty/alktunnels precedent). Caveat:
`fast-socks5` itself is not yet known to be wasm-clean (OQ-SK-04).
## Prior art
### alktunnels Phase 0 — the closest sibling
`/workspace/@alkdev/alktunnels/docs/research/phase-0-findings.md`. The
load-bearing conclusions this crate inherits:
- **Hub-owns-the-connection model** — role follows the resource;
whoever can reach the target is the producer, whoever wants the bytes
is the consumer. The "exposed port" is a virtual, ACL-scoped resource,
not a bind. For alksocks: the producer is the side that dials targets
(the egress side); the consumer is the side that wants proxied
egress. "A SOCKS5 service is the same shape [as a produced tunnel
resource], further downstream" is alktunnels' own wording — this
crate exists to make it literal.
- **`-D` composes at the assembly layer** — the original half-answer,
now with the mechanism named: a SOCKS5 server at some assembly layer
is just a consumer opening channels with per-connection dynamic
targets. The refinement this crate adds: the SOCKS5 *service* is
itself a produced resource (producer half), so a vanilla SOCKS5 client
(curl, a browser, ssh -D's own client half) can attach through the
optional local backend, and channels-native consumers get the same
service in-band. Both halves wrap the same protocol layer.
- **Target policy = resource policy.** alktunnels OQ-TN-08 dissolved
dynamic-target policy: whatever ACL governs the socks5 resource
governs everything reachable through it, plus whatever policy the
SOCKS5 implementation itself applies downstream. No target allowlists
in the base crate unless Phase 1 wants them (OQ-SK-05).
- **The codec conclusion does NOT transfer.** alktunnels needed
length-framing only for datagram substrates; a SOCKS5 CONNECT session
is a byte stream end to end (RFC 1928 defines its own framing on the
wire) — the channel payload is pure pass-through, 0 B overhead over
the channels 8-byte header. UDP ASSOCIATE's datagram stage is the
exception (OQ-SK-03).
### fast-socks5 — the implementation to wrap
`/workspace/fast-socks5` (v1.0.0, MIT, we own upstream). Read the source
before designing against it. Key surface points, verified:
- **Explicit typestate server API** (`src/server.rs`):
`Socks5ServerProtocol<T, states::Opened|Authenticated|CommandRead>`,
generic over `T: AsyncRead + AsyncWrite + Unpin`. Flow:
`start(inner)``negotiate_auth(&methods)`
`finish_auth()` / `accept_no_auth` / `accept_password_auth`
`read_command()` → (`reply_success`, `reply_error`). The legacy
`Socks5Server`/`Socks5Socket`/`Incoming` API (binds a `TcpListener`)
is deprecated — the wrapper uses the explicit API only.
- **Auth surface** — the `AuthMethod<T>` trait (metadata: `method_id`,
`new`) + `AuthMethodSuccessState<T>` (carry the socket back out);
`StandardAuthentication` enum for NoAuth + Password with static
dispatch; custom methods implement the trait. Username/password check
is a closure (`accept_password_auth`). The auth *decision* is where
the alkcall identity seam plugs in (OQ-SK-02).
- **Command handling is swappable** — the interception points:
- `run_tcp_proxy(proto, addr, timeout, nodelay)` — dials, replies,
then `transfer(inbound, outbound)` (a `copy_bidirectional` wrapper,
itself generic). This is where a channels-native dial replaces the
`tokio::net` dial: the wrapper's producer half intercepts here,
dials via alktunnels/alkcall primitives (or its own dial policy),
and returns the `T` back.
- `run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)`
— the customizable UDP ASSOCIATE handler. **Caveat (verified
2026-09-13):** the "custom" seam is narrower than it looks — it calls
`udp_bind_random_port(peer_bind_ip)` *unconditionally* and derives
the reply port from that kernel socket; the wrapper can customize
the relay loop (`transfer`) and the reply *IP* only. The channels
path therefore cannot use this handler: it drives the typestate
directly (`reply_success(sentinel_addr)` + own datagram stage). The
unconditional bind is a fileable upstream ask (we own fast-socks5):
make the bind optional or accept a caller-supplied socket.
- `transfer(inbound, outbound)` — plain two-pump copy; a channels
`BiStream` is a legal `T` on either side.
- **UDP support** — `new_udp_header(target)` / `parse_udp_request(buf)`
are public and pure (no socket I/O) — the SOCKS5 UDP datagram header
codec is reusable without the `Socket2`-based relay machinery. The
default `run_udp_proxy` binds two kernel sockets (`Socket2`, random
ports) — the *binds are in the default handler, not in the protocol*,
which is exactly the seam the no-bind requirement needs.
- **Client side** — `Socks5Stream<S>` (generic over the backing socket;
`use_stream` upgrades any `AsyncRead + AsyncWrite + Unpin` already
connected) and `Socks5Datagram<S>` (UDP associate; also accepts a
caller-supplied socket via `use_socket`). `Socks5Stream` impls
`AsyncRead + AsyncWrite` itself, so a consumer session over a
channels `BiStream` is the intended use, not a hack. Note:
`Socks5Stream::connect` convenience constructors dial
`tokio::net::TcpStream` and are non-wasm; `use_stream` is the
substrate-free path.
- **Error surface** — `ReplyError` (the RFC reply codes, `as_u8`/
`from_u8`) and `SocksError`; `SocksServerError` on the explicit API.
Map these faithfully; never collapse a refusal into a generic error.
- **`router.rs` example** — the "conditional interception" template: a
server that inspects the command/target, then decides whether to
proxy, refuse, or handle in-process. Structurally the shape of the
channels open handler (inspect params → dial or refuse) and of the
alknet ADR-090 client (hand the established stream to quinn).
### iroh-socks5 — the structural sibling (evaluated, not the base)
`crates.io/crates/iroh-socks5` (v0.5.0, MIT OR Apache-2.0, repo:
`github.com/mattgeddes/iroh-socks5` — crates.io lacks the repository
link). A ~1.3k-LoC iroh `ProtocolHandler` tunneling SOCKS5 over iroh
QUIC: framed `Request`/`Reply` protocol postcard-encoded over one
bidirectional stream, then raw pass-through (CONNECT/BIND) or framed
`Datagram`s (UDP ASSOCIATE); producer-side dial + allow-list
(`EndpointId`s), consumer-side local TCP front door for vanilla clients.
Full evaluation: `docs/research/iroh-socks5-eval.md`. Verified:
- **Independent confirmation of POC #1's shape** — one stream per SOCKS5
session, framed control phase then pass-through, RFC codec only at
vanilla-client boundaries, resolution producer-side. Convergent
evolution from a codebase with no alk* lineage.
- **The load-bearing OQ-SK-03 prior art** — its UDP ASSOCIATE design
carries framed `{addr, data}` datagrams in-stream (no RFC UDP header
on the tunnel wire; the RFC header codec applies only at the local
front-door socket), needs no producer-side flow table (per-datagram
addressing in the frame does the demux), ends the association on
control-stream EOF, and refuses FRAG ≠ 0. This is the phase-model
hunch's cleaner expression; adopt as the POC #2 design baseline.
- **Why it is not the base:** iroh types are load-bearing in the data
plane (`SendStream`/`RecvStream` in relay signatures; no generic `T`
seam), the RFC surface is a subset (no-auth only, no auth-method
machinery, no reply-code enum fidelity), and the client side has no
in-band session type (local front door only). Extracting a
substrate-agnostic core yields ~550 LoC of codecs fast-socks5 already
provides in stricter form — the POC #1/#4 base decision stands.
### alknet's SOCKS5 client + the quinn-proxy POC — the client-side ancestor
`/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` (ADR-090)
implements `Socks5UdpSocket: quinn::AsyncUdpSocket` — a SOCKS5 UDP
ASSOCIATE tunnel wrapped as the socket QUIC polls. The quinn-proxy POC
(`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/findings.md`)
validated the approach end-to-end (5/5 clean runs) against quinn 0.11:
one public trait (`AsyncUdpSocket`), one public constructor
(`Endpoint::new_with_abstract_socket`), no fork. Known limitations
there, likely inherited: ECN is lost through the SOCKS5 header; the
proxy must support UDP ASSOCIATE; `may_fragment() == true` disables
path MTU discovery.
This crate's client half rehomes that work (cleaned up, `ClientDialError`
→ thiserror types, and the datagram codec sourced from fast-socks5
rather than hand-rolled inline). The open question is noq (OQ-SK-06).
### noq — the quinn fork the client story must also fit
`/workspace/noq` (v1.2.0; iroh's fork of quinn — iroh depends on
`noq = "1.2.0"`). Surface verified:
- `noq::AsyncUdpSocket` exists (`noq/src/runtime/mod.rs:44`) but with a
**different shape** than quinn 0.11: `create_io_poller` +
`try_send` are replaced by `create_sender() -> Pin<Box<dyn UdpSender>>`
with `poll_send(transmit, cx)` — a sender-object split (any number of
`UdpSender`s per socket, each holding its own waker).
- `RecvMeta` gained fields (`interface_index`, `timestamp`) but stays
default-constructible; `Transmit` is unchanged in the fields the
SOCKS5 wrapper touches (`destination`, `contents`, `ecn`, `src_ip`,
`segment_size`).
- `Endpoint::new_with_abstract_socket` exists (`noq/src/endpoint.rs:162`)
with the same doc-comment intent, but takes
`Box<dyn AsyncUdpSocket>` rather than `Arc<dyn AsyncUdpSocket>` — the
poller-removal reshuffle also changed the ownership shape.
- The alknet `Socks5UdpSocket` does **not** drop in unchanged: the impl
must be rewritten against `create_sender`/`poll_send`, and the
`Arc``Box` change ripples into construction. Whether one crate can
serve both quinn and noq behind feature flags (shared core, two thin
trait-impl shells) or whether the shapes have diverged enough to
justify two impls is the research question (OQ-SK-06). iroh's own
adoption makes noq support the practically-important target.
### Anti-prior-art (what NOT to carry over)
- **The alknet client's hand-rolled SOCKS5 codec** —
`socks5.rs` inlines the greeting/auth/CONNECT/ASSOCIATE byte logic
twice (handshake for UDP, again for CONNECT). fast-socks5's client
types and `new_udp_header`/`parse_udp_request` supersede it; the
wrapper should not vendor a second codec.
- **`Socks5Server` (the legacy bind-based API)** — deprecated upstream;
never the default path here.
- **In-band SOCKS auth as the primary auth story** — alkcall's identity
seam (CF-005/CF-006) authorizes at the open-op layer. In-band RFC
username/password remains available only for vanilla-client
compatibility (OQ-SK-02).
## Open Questions
These are the design questions Phase 0 must resolve (or explicitly
defer) before the architecture spec. Numbered OQ-SK-01.. so they can be
referenced, tracked, and promoted into `docs/architecture/
open-questions.md` in Phase 1. Half-answers and hunches are marked as
such — the point of this document is to hold them without forcing
premature decisions.
### OQ-SK-01: Producer dial policy — what dials the target?
When the producer's SOCKS5 state machine reads a CONNECT command, the
target dial must happen *somewhere*. Options:
- **Option A: dial via alktunnels** — the producer composes with a
local alktunnels consumer: the SOCKS5 handler opens an `alk/tunnel`
channel naming the target, and the two-pump data plane is
tunnel-channel ↔ SOCKS5-`BiStream`. Maximum composition ("further
downstream" made literal), but adds a runtime dependency and a hop.
- **Option B: dial directly** — the producer dials the target itself
(`TcpStream` behind the `local` feature, or an injected dial
callback). No alktunnels dependency; the dial policy (allowlists,
routing through another alksocks hop) is the caller's callback.
- **Option C: injected dialer trait** — a `Dialer`-shaped trait
(`async fn dial(target) -> impl AsyncRead + AsyncWrite`) with
alktunnels and local-TCP implementations behind features. Middle
ground; the trait is a backend-inversion-point decision (compare
alktunnels OQ-TN-05's resolution: *no trait*, a function producing
boxed halves sufficed).
Considerations: substrate-agnostic-by-construction (AGENTS.md
convention 7) favors injection over hardwiring; the "hub terminates and
re-produces" story means a hub-hop SOCKS5 resource's dialer is just
"open a channel further downstream," which is Option A's shape — so
whatever is decided must not *prevent* A when composing. Hunch: a dial
callback/trait at the protocol layer, with alktunnels and local-TCP
providers feature-gated — but alktunnels' no-trait precedent warns
against a trait unless two real implementations converge; decide with
the Phase 1 spec.
### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth
The producer's open-op hooks receive the per-call opener identity
(alkcall CF-005/CF-006). The SOCKS5 RFC conversation also carries its
own optional username/password auth (RFC 1929). Questions:
- Is the identity seam the *only* auth story (the open op is
authorized; the in-band handshake is skipped via
`skip_auth_this_is_not_rfc_compliant` or a no-op NoAuth), with
RFC-facing username/password available only for the optional local
backend (where vanilla SOCKS5 clients connect)?
- If both exist, how do they compose — does in-band auth *replace* the
channel identity for target-policy purposes, or is it a second gate?
- Does the wrapper need a fast-socks5 `AuthMethod` implementation that
consults the alkcall `AuthContext`/`Identity` (filed upstream if the
hook shape doesn't fit — AGENTS.md convention 17)?
(Answered in direction by the 2026-09-13 posture statement below:
yes / planes-don't-compare / probably-not — Phase 1 formalizes.)
**Posture — settled direction (2026-09-13 discussion): "both," split
by the door.** The planned end-to-end story is the vpn-like
composition: a consumer connects to an endpoint producing the SOCKS5
resource, *optionally* binds it locally (component 2's front door —
ssh `-D`'s shape, with the defining difference that no port is bound by
default), and points tun2proxy (`--proxy socks5://user:pass@host:port`
natively, per §Vision principle 6) at the local port. That makes "both"
auth mechanisms real, each on its own side of the door:
- **Producer side: identity only.** The channels-native open is
authorized by the alkcall ACL/identity seam (CF-005/CF-006); a
producer-side password is moot — the ACL system is strictly better
than a shared secret, and the producer's in-band conversation runs
NoAuth/no-op. A password there would add nothing the identity seam
doesn't already do.
- **Consumer side (the front door): RFC 1929 where vanilla clients
need it.** The door is where the vanilla world meets the ACL world,
so it owns credential presentation: a vanilla client carrying
`user:pass` (tun2proxy, curl) 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.
The "replace or second-gate" sub-question dissolves: the mechanisms
occupy different planes (in-band creds are door-local; identity is
channel-level). The NoAuth door (POC #1's raw-pipe shape, zero
protocol translation) remains valid for loopback-only or wrapper-aware
use; the cred-bearing door is the same bridge plus the greeting half
(fast-socks5's server typestate for auth + `read_command`, then the
consumer session in-band) — an assembly-layer choice, not a protocol
one.
Remaining for Phase 1: the door's credential→identity mapping shape
(static config vs pluggable lookup); whether the producer-side
optional local backend offers the same door surface (same code shape —
door = vanilla↔ACL boundary — so presumably yes); and whether the door
needs a fast-socks5 `AuthMethod` impl or simply checks credentials
itself before handing the stream to the typestate (likely the latter —
the door is this crate's code, the trait was designed for embedders
who don't own the accept loop).
### OQ-SK-03: UDP ASSOCIATE without binding — the hard case
RFC 1928 §7: the client sends UDP ASSOCIATE over the TCP control
connection; the server replies with a UDP relay address (`BND.ADDR`/
`BND.PORT`); the client then sends UDP datagrams (SOCKS5 UDP header +
payload) *to that address*. On channels there is no UDP relay socket —
the datagram stage must ride a channel. Sub-questions:
- **Where does the datagram stage live?** Options:
- **Same channel, extended protocol** — after ASSOCIATE, the channel's
`BiStream` carries length-prefixed datagrams (the alktunnels UDP
codec shape, `[len: u16 BE]` per datagram; 65507 < 65535 so u16
suffices). The reply to the client rewrites `BND.ADDR`/`BND.PORT`
— originally imagined as a sentinel address meaning "same channel,"
but the handle swap below supersedes that: no virtualized address
is needed at all, because the in-band relay handle is the channel
itself, not an address. Vanilla SOCKS5 clients will literally
`sendto()` whatever address the reply carries, so this shape only
works for *wrapper-aware* client halves (the crate's own consumer
session, or an assembly layer bridging a real UDP socket).
**iroh-socks5's refinement (2026-09-13 eval):** the in-band datagram
carries `{addr, data}` (boundary-preserving, no RFC header on the
tunnel wire) and the RFC UDP-header codec lives only at the
vanilla-client front doors — cleaner than putting the length
framing *inside* the RFC header stream as first hunched; see
`docs/research/iroh-socks5-eval.md`.
- **Second channel for the datagram stage** — the producer
establishes the association on channel 1, then the client opens a
second channel that becomes the relay. Keeps CONNECT-shaped
`BiStream` semantics clean but adds an open-op round trip and
needs correlation (params carry the association id?).
- **Local-backend bridge only** — UDP ASSOCIATE is offered only
through the optional local backend (which binds a real UDP relay
socket as the assembly layer's explicit choice), never
channels-natively. Simplest; weakens the "ALPN as a service"
story for UDP.
- **What does `BND.ADDR`/`BND.PORT` reply contain on the channels
path?** Largely resolved (2026-09-13, see the handle-swap discussion
below): on the in-band path the relay handle *is the channel itself*
— no address needs to be invented, and the reply's `BND.ADDR`/
`BND.PORT` are pure RFC-compatibility surface (zeros, or whatever
the front-door bridge reports). Only the vanilla-client front door
has a real socket address to report, and there it comes from the
real relay socket the assembly layer chose to bind.
- **Per-datagram addressing** — the SOCKS5 UDP header carries the
destination per datagram (ATYP + addr + port), so one association
multiplexes many endpoints. alknet's model used a producer-side
per-endpoint flow table; iroh-socks5 demonstrates the simpler cut —
per-datagram addressing *in the frame* does the demultiplexing, no
flow table. Boundary preservation is mandatory (empty
datagram ≠ EOF — alktunnels F-2's lesson transfers directly; the
`{addr, data}` frame shape meets it structurally).
- **Do vanilla (wrapper-unaware) clients need channels-native UDP at
all?** If the only UDP ASSOCIATE consumers are wrapper-aware, the
in-band shape needs no relay address at all (the handle is the
channel — see the handle swap below) and no virtualized address is
ever invented.
**The handle swap (2026-09-13 discussion) — "tell the client about a
UDP port" → "tell the client about the UDP tunnel."** The design's
load-bearing reading of RFC 1928: 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.
So swapping the handle preserves every RFC property:
- one relay per association → **one channel per association** (the
CONNECT shape; no second channel, no correlation params);
- per-datagram destinations → `{addr, data}` frames in-band;
- lifetime tied to the control connection → channel EOF;
- "bind locally if you want" → the optional front door on either side
(a real UDP socket bridged to the channel, RFC codec at that
boundary only).
Vanilla clients still literally `sendto()` the replied address, so the
in-band path is wrapper-aware-only by RFC necessity — the front-door
bridge is where a real socket address exists.
**Producer egress — two variants (the "no ports" story has two
halves).** The handle swap settles the *consumer-facing* half. The
*producing-side* half is: after `{addr, data}` frames exit the channel,
what gets each datagram to `addr`?
1. **Local egress** — the handler binds a real `UdpSocket` and
`send_to`s per datagram (iroh-socks5's `relay_udp_server` behind
this crate's `local` feature). No flow table, native-only.
2. **Composed egress (OQ-SK-01 Option A for the datagram stage)**
the dial callback opens an alktunnels `udp`-substrate tunnel per
destination: a lazy destination→tunnel table (open on first
datagram, LRU-evict). The flow table is *reborn as channel
handles* — because alktunnels' udp substrate is **connected**
(`connect_udp(target)`: one tunnel, one fixed target) while
ASSOCIATE names a different destination per datagram. Cost: one
open per destination (the per-connection/per-identity channel
defaults are 256 — ADR-040/041 policy knobs, configurable without a
wire change; the `u32` wire space is ~4 billion — so fine for DNS +
browsing, and the per-destination open latency is a POC #2
measurement). Benefit: **per-target ACL for free** — the ACL
governing the udp-tunnel resource governs egress per destination
(dissolving most of OQ-SK-05 for the composed path), and hub
relaying is per-target terminate-and-re-produce.
Both variants are dial-callback policy (OQ-SK-01's shape) — nothing in
the fork or the protocol layer sees the difference. **No upstream ask
exists here:** alktunnels' connected-udp substrate is correct as-is for
per-target composition; an "unconnected arbitrary-egress udp resource"
upstream would just be SOCKS5 again with different framing (circular).
Prior note retained: fast-socks5's `run_udp_proxy_custom` lets the
wrapper supply the reply and the relay half — the seam exists upstream;
the local-egress variant uses it or drives the typestate directly (the
unconditional-bind caveat above).
**Phase model vs alktty-style demux (2026-09-12 discussion).** An
alternative shape surfaced: alktty's logical demux (input/output/error/
control sub-streams inside one channel, type byte per chunk, up to the
u8 = 255 stream-type limit) applied to the SOCKS5 channel — a `data`
stream and a `udp-relay` stream type would carry the two phases
structurally. Analysis against RFC 1928's actual structure: **RFC 1928
is one command per connection, and control and data never interleave in
either direction.** CONNECT's control conversation ends at the reply,
after which the channel is a pure byte stream (alktunnels' 0 B
pass-through conclusion); ASSOCIATE's control stream goes silent after
the reply — fast-socks5's own `wait_on_tcp` (`src/server.rs:1195`)
treats any post-reply control byte as protocol garbage
(`UnexpectedUdpControlGarbage`). So the per-chunk type byte demuxes a
problem SOCKS5 doesn't have; a **phase model** (raw RFC conversation
until the reply, then a mode switch synchronized by the reply itself —
pass-through for CONNECT, `[len: u16 BE]` datagrams for ASSOCIATE)
subsumes it with no type byte and no sentinel address: wrapper-aware
clients know "post-reply = datagram mode," the same way they'd know
"relay stream = datagram mode" under the demux. What the demux genuinely
buys that the phase model does not: an **additive in-band vocabulary**
new stream types extend the wire additively (no format break), whereas
adding a post-reply phase later is a wire break. alktunnels accepted the
opposite ("no in-band control path, ever — the escape hatch is a new
ALPN, a wire change is not"); the demux reintroduces that vocabulary at
2 B/chunk (type byte + length prefix) on every datagram. The one-way-door
cost is real either way: demux bakes the vocabulary in before it has a
user; phase model keeps 0 B overhead on CONNECT and 2 B on ASSOCIATE
datagrams. Hunch: phase model (simpler, RFC-shaped); the extensibility
argument is the honest case for the demux if Phase 1 wants the
vocabulary. Decide via ADR with the first consumer in sight.
Hunch (datagram stage, updated 2026-09-13 by the handle swap):
wrapper-aware client halves ride the same-channel `{addr, data}` frame
shape (one channel per association, per-datagram addressing in-band,
channel EOF = association lifetime); vanilla clients get UDP ASSOCIATE
only via the optional local front-door bridge. The remaining choice is
producer-egress policy (local socket vs per-target alktunnels
composition) — a dial-callback question owned by OQ-SK-01, with the
composed variant's ACL benefit making it the hunch for the base crate's
composed story. But this is still the crate's largest unknown — a POC
candidate (OQ-SK-07 #2), decided by an ADR before the first consumer
(the datagram framing is wire-stable once published).
### OQ-SK-04: fast-socks5 wasm posture (blocks the wasm-clean invariant)
`fast-socks5` uses `tokio::net` (`TcpListener`, `TcpStream`,
`UdpSocket`) and `socket2` unconditionally (`Cargo.toml`: tokio features
`io-util, net, time, macros`; deps `socket2 = "0.5.8"`); `wasm32-
unknown-unknown` has no `tokio::net`. The wrapper's own protocol layer
can stay wasm-clean (the typestate API is generic over `T`), but
depending on the crate at all may break `cargo check --target
wasm32-unknown-unknown` at the dependency-graph level.
**The decision tree (2026-09-12 clarification — two main forks, and the
preference between them is conditional):**
- **Case 1: wasm matters for this crate → fork-first.** Make the
minimal fork and use it — offer it upstream as a PR (merge if they
want it; we carry the fork regardless; never wait on approval).
The gating change is genuinely small (feature-gate
`tokio::net`/`socket2` behind a default-on `net` feature, per the
alktty `local` pattern); the long-term cost of maintaining a
minimal fork of a well-written lib trends toward zero. Concretely
"fork" 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 PR, the fork shrinks to a plain dependency.
- **Case 2: wasm doesn't matter for this crate → plain dependency, no
fork.** Ride the `router.rs` native path exactly as-is (the
alkhttp precedent — a crate in this suite can ship native-only).
This is strictly better *if its premise holds*: no fork burden, no
vendoring, upstream stays upstream. The alkhttp contrast is
instructive — alkhttp's substance (axum/hyper/reqwest) is
socket-native, so nobody misses wasm there.
**The root question is therefore: does wasm make sense for alksocks?**
Not "how do we get wasm" — that is Case 1's solved problem. Analysis
for the decision:
- **What wasm would serve:** the protocol layer (components 1+2 of the
three-component split, §Vision) in a sandboxed adapter — the
alktty/alktunnels posture that a wasm-compiled protocol crate is the
protocol layer for downstream TS/Python adapters. The natural wasm
shape is a **consumer**: speak RFC 1928 client-side over a channels
`BiStream` (pure byte framing; the producer dials targets, so no
local sockets are needed on the client side). A wasm **producer** is
odd standalone (no kernel egress) but composes — its dialer can open
alktunnels channels further downstream, which is the hub story.
- **What wasm would not serve:** component 3 (the quinn/noq client
wrapper) is native-only in practice — it wraps kernel UDP sockets for
UDP ASSOCIATE; no wasm story is expected there regardless.
- **The alkhttp contrast, honestly weighed:** for alkhttp,
"an HTTP client accessed over a channel" was judged weird, and the
crate ships native-only. alksocks differs structurally: its protocol
substance *is* byte-framing over a generic `T` — the part that costs
nothing to keep wasm-clean. But "costs nothing under the fork" is
only an argument once Case 1 is chosen; it is not itself the use
case. The use case is a planned sandboxed SOCKS consumer.
**Decision inputs (what actually settles Case 1 vs Case 2):**
1. **Is there a planned sandboxed (wasm) consumer of the SOCKS5
service?** If the downstream TS/Python adapter story includes a
channels-connected sandbox that wants proxied egress, wasm matters
→ Case 1. If not → Case 2.
2. **Is the fork genuinely minimal?** POC #4 (OQ-SK-07) verifies
empirically how invasive the gating is. If the gating turns out
structural (the `T`-generic surface can't be separated from
`tokio::net` without a rewrite), the fork cost rises and Case 2
gains weight — reimplementing the protocol to serve a wasm story
nobody has yet defeats the purpose.
Both inputs point the same way when aligned: fork-first in Case 1 is
the preferred branch *because* wasm is wanted there — not as a general
preference for forks over upstream asks. If the adapter story never
materializes, Case 2 (plain dep, router path) is the obvious choice,
and no one should carry a fork for an invariant's sake.
### OQ-SK-05: Target policy and egress scoping
SOCKS5 is arbitrary-egress by nature — the open gate plus the target
policy are the security boundary (AGENTS.md convention 12). alktunnels
resolved that whatever ACL governs the socks5 resource governs
everything reachable through it; OQ-SK-01's dial policy is the
mechanism. Residual questions for Phase 1 (note the 2026-09-13
OQ-SK-03 handle-swap finding: for the *composed* UDP egress path,
per-target ACL comes free — each destination rides its own
alktunnels udp-tunnel channel governed by the tunnel resource's ACL,
dissolving most of this OQ for that path):
- Does the base crate ship a per-target allowlist hook (producer-side
policy injected at registration), or is target policy entirely the
dialer's concern (the dial callback refuses)? The dialer-refuses
shape avoids a second policy layer (alktunnels' resolution pattern);
the allowlist-hook shape makes a common policy declarative.
- Is there a `SOCKS5_OPEN_SCOPE` (scope-gate on the open op, alktty
`TTY_OPEN_SCOPE` shape) — presumably yes, but the exact scope-string
convention should follow alkcall's registry conventions.
- Domain-form targets (RFC 1928 ATYP 0x03): resolve producer-side
(fast-socks5's `dns_resolve` config), refuse, or pass through to the
dialer unresolved? (DNS-on-the-producer is the SSH `-D` semantic;
wasm producers may not have a resolver at all — another OQ-SK-04
interaction.)
**Virtual address space / identity-scoped subnets (added 2026-09-14,
post-POC #2).** A proposal surfaced during the findings review: give
identities scoped virtual subnets (e.g. 10.x.0.0/16 slices) so proxied
resources become IP-addressable — "BND.ADDR ↔ channel id" translation,
a hub exposing proxied resources per identity under ACL. The goal is
adoptable; 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
(OQ-SK-01's shape: `dial(target)` matches a virtual subnet against a
per-identity resource table), per-identity scoping is resources + ACL
+ target policy (this OQ), and hub exposure is the
terminate-and-re-produce story with no new wire vocabulary. BND.ADDR
stays per the handle swap (OQ-SK-03): in-band the handle is the
channel (compat surface only); a real network-B address exists
wherever a local backend binds — the listener's `local_addr` (OQ-SK-08
variants 12 cover BIND's reply#1). The one place a virtual subnet
becomes *routable from vanilla apps* is the full-tunnel composition:
the host routes the subnet into the proxy (tun2proxy), so
`connect(10.x.y.z:p)` is an ordinary CONNECT the producer translates.
Worth checking tun2proxy's virtual-network/virtual-DNS support as
prior art before Phase 1 decides whether the crate ships a
translation-table helper or leaves it entirely to assembly-layer
policy.
### OQ-SK-06: noq client support (and the quinn/noq shape split)
**Scope clarification (2026-09-12):** the SOCKS client wrapper is
component 3 of the three-component split (§Vision) — a standalone
client library for downstream users (alknet) that must work with *any*
RFC 1928 server, not just one this crate produces. The motivating case
is iroh's privacy posture: iroh relays (and the peer) see the client's
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>`.
Questions:
- Does this crate ship `Socks5UdpSocket` against noq (behind a feature
like `noq`), quinn (behind `quinn`), or both? Both implies a shared
substrate-free core (associate handshake, datagram codec, flow table)
with two thin trait shells — feasible only if the shared core is
genuinely trait-agnostic. iroh's adoption makes noq the
practically-important target; alknet's precedent is quinn.
- noq is not on crates.io at this version (iroh consumes it from the
n0 workspace/git) — how does this crate depend on it (git dep? wait
for publication? feature-gate so it is optional)? This may block
`cargo publish --dry-run` for the client features; a publish-lean
default (quinn optional, noq git-optional) may be needed.
- Do the ECN/MTU limitations carry over (they should — the SOCKS5 UDP
header has no ECN field), and does noq's `may_fragment` default
(`true`) behave the same as quinn's?
This is a research question first (read noq's endpoint/driver loops;
write the trait-shape comparison), then possibly a POC (OQ-SK-07 #3).
**Resolved by the scope decision (2026-09-14, §Vision): transferred to
the alknet rewrite.** The client wrapper is alknet's ancestor-shaped
component (ADR-090 + quinn-proxy POC already live there), consumes no
channels, and has no consumers planned inside this crate — so its
research questions (noq trait-shape comparison, dependency posture,
ECN/MTU carry-over) move to alknet's Phase 0 wholesale. This crate's
client-side story is complete without it: the channels-native consumer
session is POC-validated (POC #1's `use_stream` over a `BiStream`,
POC #2's associate session). The noq POC (OQ-SK-07 #3) is dropped from
this crate's POC list accordingly.
### OQ-SK-07: POC scope for what remains unvalidated
Candidates, in rough priority order (per the SDD process's "validate
promising approaches"):
1. **Channels-native SOCKS5 CONNECT POC** — the producer half over a
real alkcall channels connection: consumer opens a SOCKS5 channel,
speaks RFC 1928 CONNECT (via fast-socks5's client types), producer's
open handler runs the typestate machine over the `BiStream`, dials a
local echo target, two-pump proxy loop completes. Validates: the
`register_openable_with_establisher` fit, `pump_bidi` as the data
plane, params shape (trivial — probably no params), and the
`BiStream`-as-`T` genericity claim. This is the crate's core value
proposition and the cheapest to validate (alktunnels' forward POC is
the template). **Run 2026-09-13 — passed** (`alksocks-connect-poc`,
8 tests + a curl front-door example; findings in
`poc-connect-wasm-findings.md`: dial-at-command-read layering
resolved, identity seam works, refusal paths typed).
2. **UDP ASSOCIATE POC** — the OQ-SK-03 chosen shape, end to end: a
wrapper-aware client associates, sends length-prefixed datagrams
down the channel, producer relays to a real UDP endpoint. Validates:
the sentinel-reply semantics, the datagram codec (fast-socks5's
`new_udp_header`/`parse_udp_request` over the length framing), the
flow table, and empty-datagram handling (alktunnels F-2 transfers).
(Reuses POC #1's producer skeleton; note `run_udp_proxy_custom`
cannot be used as-is — see the corrected §Prior art caveat.)
**Design baseline updated 2026-09-13:** the iroh-socks5 eval
(`docs/research/iroh-socks5-eval.md`) suggests the in-band datagram
carries `{addr, data}` directly (no RFC UDP header on the tunnel
wire; RFC codec at front doors only) and drops the producer-side
flow table (per-datagram addressing in the frame); the sentinel
question may dissolve for the in-band path (no `BND.ADDR` needed
when the datagram stage needs no relay address).
**Baseline settled by the handle swap (2026-09-13, OQ-SK-03):** one
channel per association; `{addr, data}` frames in-band; channel EOF
ends the association; RFC codec only at front doors. The POC should
sketch **both egress variants** behind the dial callback — local
`UdpSocket` (no flow table) and composed per-target alktunnels
udp-tunnels (lazy destination→tunnel table, LRU) — and measure the
per-destination open cost of the composed variant empirically.
**Run 2026-09-14 — passed** (`alksocks-udp-poc`, 11 tests; findings
in `poc-udp-associate-findings.md`: the handle-swap data plane
validated end to end, both egress variants implemented + composed
per-destination open cost measured at ~0.8 ms, front-door RFC-codec
composition confirmed, select!-driven stage with the egress
readiness contract as the load-bearing API finding).
3. **noq `AsyncUdpSocket` impl POC** — the client-side story against
noq 1.2: associate through a fast-socks5 server, wrap as
`noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06
(the `create_sender`/`Box` shape changes) before the client spec is
written. (The quinn variant is already proven by the quinn-proxy
POC — re-validating it here is optional.)
**Dropped from this crate (2026-09-14, the scope decision):** the
client wrapper transfers to the alknet rewrite (§Vision) — OQ-SK-06
and this POC go with it. (At the time of the decision the list was
#1, #2, #4 — all passed; #5 (BIND) was added later with OQ-SK-08.)
4. **fast-socks5 wasm check** — minimal crate, `cargo check --target
wasm32-unknown-unknown`, confirm/inflect OQ-SK-04. Cheap; can fold
into #1's worktree. **Run 2026-09-13 — fork-minimality verified**
(~120 lines of `#[cfg]` insertions; wasm-clean `--no-default-
features` build + functional RFC round-trip test pass; findings in
`poc-connect-wasm-findings.md`).
5. **BIND POC** — the RFC 1928 BIND command channels-native
(OQ-SK-08): the producer drives the typestate directly
(`read_command()` → match `TCPBind` — fast-socks5 parses but
refuses the command, `lib.rs:384-386`/`server.rs:813-816`; the
wrapper owns the whole BIND shape, no upstream ask), the phase
model extends naturally (request → reply#1 with `BND.ADDR` →
quiet wait → one inbound accept → reply#2 with the peer address →
raw pass-through data plane, zero per-packet headers in-band;
iroh-socks5's channels-native BIND is the convergent precedent,
`docs/research/iroh-socks5-eval.md` §BIND). Sketch **both accept
variants** behind the accept callback (OQ-SK-08): a feature-gated
real `TcpListener` backend (faithful RFC — a real network-B
address in reply#1, vanilla front-door clients work, FTP active
mode as the canonical test) and the composed alktunnels
listen-tunnel variant (`register_tunnel_listen_openable` +
`listen_establisher` + `AcceptQueue` — per-listener ACL for free,
hub-relayable). Plus a wrapper-aware in-band test and a vanilla
front-door BIND test ("app server" dials the bound address after
reply#1). Validates: the accept-side data plane (the mirror of the
dial callback), reply#1/#2 sequencing on one channel, and the
alktunnels listen-addr ask's shape. Completes the RFC 1928 command
surface (all three commands) in the crate.
POC placement conventions (inherited from alktunnels): a POC that needs
code from this repo runs in a worktree/branch (`.worktrees/research/
<task-id>/` per the SDD process); a self-contained POC runs as a
standalone crate in the global workspace with findings written into
`docs/research/` here. Findings always land in `docs/research/`
regardless of where the code lives.
### OQ-SK-08: BIND — the accept-side command (use case found)
**Status: new (2026-09-14, post-POC #2).** Both POCs refused BIND as
RFC-correct; the findings review surfaced a real use case and a
mechanism, so "out of scope until a use case exists" has been met.
**The gap, precisely:** fast-socks5 parses `TCPBind`
(`Socks5Command::TCPBind`, `lib.rs:91`) but every handler refuses it
(`lib.rs:384-386`, `server.rs:813-816` — `CommandNotSupported`); no
bind machinery exists. The typestate flow (`read_command()` returning
`cmd` + `TargetAddr`) means the wrapper drives the command dispatch
itself — implementing BIND is wrapper-side code, **zero upstream
ask** (unlike UDP ASSOCIATE's `run_udp_proxy_custom` bind, which sits
inside an upstream handler).
**The shape (convergent precedent: iroh-socks5's channels-native
BIND, `docs/research/iroh-socks5-eval.md` §BIND):** one channel per
BIND session, phase model — request → reply#1 carrying `BND.ADDR` →
quiet wait (any control byte after reply#1 is protocol garbage, same
as ASSOCIATE's post-reply stage) → 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). RFC-refusal of BIND remains valid for old
consumers; support is purely additive (same ALPN, same params, no
framing change — no one-way-door exposure).
**The mechanism — accept-side resources (the `-R` far-side listener
shape).** The consumer's `-R` path in alktunnels
(`register_tunnel_listen_openable` + `listen_establisher` +
`AcceptQueue`, `alktunnels/src/producer.rs:207,392`) proves accept-side
resources exist without forced binds; SOCKS5 BIND is their mirror
image: the producer-side listener (network B — the app server must
reach it) is the accept resource, each popped accept two-pumps against
the SOCKS5 `BiStream`. The phase-0 hand-off note ("a producer-side
BIND wants the accept-side shape, not a dial") predicted exactly this;
the composition supplies the use case it lacked.
**Accept variants (mirror of OQ-SK-03's egress variants) — the one
real design question is address-first vs accept-first:** RFC BIND is
address-first (bind → advertise in reply#1 → client tells the app
server → it connects → accept → reply#2), while the listen establisher
is accept-first (opening the channel pops an already-accepted
connection — you cannot open before reply#1 to learn the address).
Three resolutions:
1. **Local backend binds for real** (feature-gated `local`) — the
faithful RFC shape, iroh-socks5-faithful. A real network-B address
goes in reply#1, vanilla front-door clients work; FTP active mode
is the canonical use and the reason vanilla client libraries speak
BIND at all. No upstream ask.
2. **Composed + small alktunnels ask** — the listener lives on a
listen resource (per-listener ACL for free, hub-relayable per the
terminate-and-re-produce story); needs "learn the bound address
without consuming an accept" — the plan carries `local_addr` +
an inspect-only open, or a `listen/addr` query op. Small ask; we
own alktunnels (the same finding-first pattern that landed the
E-01/E-02 sweep in alkcall 0.5.0).
3. **Wrapper-aware only** — zeros in reply#1, reply#2 in-band;
simplest, but vanilla clients lose BIND and the use case mostly
evaporates. Retained for completeness; weak hunch against.
Variant 1 is the POC #5 hunch (faithful + zero ask), with variant 2
sketched behind the same accept callback — the composed variant's
value (hub story, per-listener ACL) is the reason to measure it, and
its blocker is the address-inspection ask.
**Security boundary note:** BIND is an *inbound* listener — the
accept-side twin of arbitrary egress. Whatever ACL governs the socks5
resource now also governs who can mint listeners on the producer's
network; per-listener policy (address/port ranges allowed to bind) is
the accept-callback's target-policy twin (OQ-SK-05's mirror). The
open-op gate (`SOCKS5_OPEN_SCOPE`) must distinguish CONNECT/ASSOCIATE
from BIND, or any identity with proxy egress can also open ingress
listeners.
### OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
**Status: new (2026-09-14, post-POC #2).** The 256-channel default
cap (per-connection 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 (OQ-SK-03) spends a downstream channel per destination; BIND
(OQ-SK-08) will spend an accept resource per listener). The caps are
policy knobs — configurable without a wire change, the `u32` wire
space is ~4 billion — so the resolution is config, not wire:
- **No wire change.** Rejected permanently as unnecessary: the u32
channel-ID space is not the constraint; the caps are policy.
- **Assembly-layer config** is the mechanism (ADR-040/041 are
explicitly "policy knobs, configurable without a wire change").
A proxy-shaped deployment raises the per-connection/per-identity
caps; the default stays conservative for everyone else.
- **Documented proxy-workload default** for Phase 1: the crate docs
should recommend a raised cap (and its memory arithmetic: a session
is bounded + predictable — CONNECT/ASSOCIATE are bounded-buffer
pumps, BIND adds one listener + one accept) so deployments don't
discover the cap in production.
- **Defense-in-depth posture:** the cap's rationales both still apply
under SOCKS5. Per-connection memory bound — arguably *weaker* here,
since the three command shapes are well-known and bounded
(a session's memory is predictable); per-identity DoS brake —
arguably *stronger* here, since each SOCKS5 session maps to real
producer-side resources (a dial, a downstream channel, an accept
listener) and the cap is the brake on one identity opening
thousands. Layered defense: ACL → per-connection cap → per-identity
cap → dial/accept policy; relaxing the caps leans on the rest.
**Considered and rejected: in-channel port multiplexing.** A related
proposal (2026-09-14 review) would reframe the cap by multiplexing
"ports" inside one channel — a `[port: u16][len][payload]` per-chunk
prefix (channel-id-as-IP, port-as-port; modeled on alktty's wire).
Rejected on the established grounds: it re-opens the demux-vs-phase
trade OQ-SK-03 already resolved (phase model, no in-band control
vocabulary needed across both POCs), it breaks the 0-B pass-through
that lets fast-socks5's raw `transfer` drop in unchanged (POC #1's
validated fit), it imposes chunk boundaries on a stream RFC 1928
treats as unstructured, and it violates the one-channel-per-SOCKS5-
session convention (AGENTS.md #10). Channel-ID economy is not a wire
problem the u32 space + policy knobs fail at. **Revisit trigger:** 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.
**The related idea that survives — virtual address space /
identity-scoped subnets** — is recorded under OQ-SK-05 (added
2026-09-14): BND.ADDR/DST.ADDR translation via the dial callback,
no wire change.
## Survey / prior-art list
Candidate reading for the research specialist (to be expanded):
- RFC 1928 (SOCKS5) — the fixed protocol: method negotiation, CONNECT,
BIND, UDP ASSOCIATE, reply codes. RFC 1929 (username/password).
- fast-socks5 `/workspace/fast-socks5` — `src/server.rs` (typestate
API, interception points, auth traits), `src/client.rs`
(`Socks5Stream`/`Socks5Datagram`, `use_stream`), `src/lib.rs`
(`new_udp_header`/`parse_udp_request`, `ReplyError`),
`examples/router.rs` (conditional interception), `examples/
custom_auth_server.rs`.
- iroh-socks5 — `crates.io/crates/iroh-socks5` v0.5.0, repo
`github.com/mattgeddes/iroh-socks5` (crates.io has no repository
link; the published source matches). Evaluated in
`docs/research/iroh-socks5-eval.md`: not the protocol base (iroh
coupling, RFC subset), but the OQ-SK-03 UDP prior art (framed
`{addr, data}` in-band datagrams, no flow table, control-EOF
association lifetime) and an independent confirmation of POC #1's
one-stream-per-session shape.
- alktunnels — `docs/research/phase-0-findings.md` (the `-D`
composition conclusion, hub-owns-the-connection, the UDP codec
decision + F-2), `docs/architecture/` (params/ALPN/ACL ADR template),
POC summaries (POC placement and findings conventions).
- alkcall — `docs/architecture/decisions/` ADR-037/039/049/050 (channel
ops, params-is-ALPN-specific, establishment, pump_bidi), ledger
CF-005/CF-006 (identity seam), `src/channels/operations.rs`
(`ChannelCore`, `OpenHandler`, `Establishment`, `ChannelPlan`),
`src/channels/pump.rs`.
- alknet — `crates/alknet-client/src/socks5.rs` (ADR-090, the client
ancestor), `docs/research/quinn-quic-proxy/findings.md` (the POC
findings: trait + constructor surface, ECN/MTU limitations,
BotBrowser production precedent).
- noq — `/workspace/noq`: `noq/src/runtime/mod.rs` (`AsyncUdpSocket`,
`UdpSender`), `noq/src/endpoint.rs` (`new_with_abstract_socket`),
`noq-udp/src/lib.rs` (`RecvMeta`, `Transmit`), `Cargo.toml` (workspace
versioning / publication posture).
- alktty — backend inversion point (`TtyBackend`), `TTY_OPEN_SCOPE`
scope-gating shape, feature-gated `local` backend, wasm-clean
default-crate verification commands.
- tun2proxy — `/workspace/tun2proxy` `src/udpgw.rs` and its SOCKS5
files (`socks.rs`, `proxy_handler.rs`): UDP-over-stream framing and
flow-table prior art (analyzed in alktunnels phase-0-findings; the
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
posture: `noq = "1.2.0"` from the n0 workspace) — context for
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)
- [ ] Vision + guiding principles captured (this doc, §Vision —
including the three-component split)
- [ ] Prior-art pass complete: fast-socks5 surface verified (§Prior
art), alktunnels/alknet/noq lineage mapped, anti-prior-art list
written; iroh-socks5 evaluated (structural sibling, not the base;
OQ-SK-03 prior art — `docs/research/iroh-socks5-eval.md`)
- [x] OQ-SK-01 (dial policy) — researched, half-answered (injected
dialer hunch); **POC #1 resolved the shape empirically** (dial
callback at the command-read interception point, boxed halves, no
trait); final decision in Phase 1 against the spec
- [x] OQ-SK-02 (auth mapping) — posture drafted (identity seam
primary, in-band auth for the local backend); **POC #1 validated
the seam end to end** (per-call opener identity at the
establisher; channel-0 identity propagation is a producer
harness wiring requirement); **direction settled 2026-09-13**
("both," split by the door: producer-side identity only — a
password there is moot against the ACL; consumer-side front door
owns RFC 1929 for vanilla clients and maps credentials to the
channel identity); Phase 1 formalizes the door's
credential→identity mapping
- [ ] OQ-SK-03 (UDP ASSOCIATE) — the shape space written (including
the phase-model-vs-alktty-demux analysis); resolve via research
+ POC #2, ADR before the first consumer
**(POC #2 run 2026-09-14 — passed; the handle-swap baseline is
POC-validated end to end, findings in
`poc-udp-associate-findings.md`; the datagram framing is
wire-stable once published — the Phase 1 ADR decision remains)**
- [ ] OQ-SK-04 (fast-socks5 wasm) — the decision tree is written
(Case 1: wasm wanted → fork-first; Case 2: wasm unwanted →
plain dep, router path); **POC #4 verified the fork is genuinely
minimal** (~120 lines of cfg gating; wasm-clean subset is
functionally complete); the root question ("does wasm make
sense for alksocks?") now reduces to the adapter-story input
- [ ] OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision;
scope-gate convention pinned in Phase 1; the 2026-09-14
virtual-subnet proposal (dial-callback translation, no wire
change) recorded for Phase 1
- [ ] OQ-SK-08 (BIND) — new (2026-09-14): the gap + use case +
accept-side mechanism written; resolve via POC #5 + the Phase 1
ADR (ship in base crate? default accept variant?)
- [ ] OQ-SK-09 (channel capacity) — new (2026-09-14): the proxy-
workload analysis written (wire change rejected; config is the
mechanism; in-channel port multiplexing considered and
rejected); Phase 1 pins the documented proxy-workload
recommendation
- [x] OQ-SK-06 (noq client) — **resolved by the scope decision
(2026-09-14, §Vision): transferred to the alknet rewrite**
(the client wrapper is alknet's ancestor-shaped component,
consumes no channels, has no consumers planned here); its
research questions move to alknet's Phase 0; this crate's
channels-native client story is POC-complete without it
- [x] Targeted POC(s) run + summaries in `docs/research/`
(three complete: #1 + #4 in `poc-connect-wasm-findings.md`,
#2 in `poc-udp-associate-findings.md`; #3 dropped with the
scope decision; **#5 (BIND) added 2026-09-14 with OQ-SK-08 —
pending**)
- [x] Converge: recommended approach written up, ready to hand to the
Architect for Phase 1 (§Convergence, 2026-09-14)
## Convergence — the recommended approach
This is Phase 0's output: a clear WHAT/WHY with validated approaches,
ready for the Architect.
### The crate, scoped
**alksocks = the SOCKS5 producer/consumer protocol pair on alkcall
channels (socks + channels, nothing else).** The three-component split
resolved: components 1+2 are this crate; component 3 (the quinn/noq
`AsyncUdpSocket` client wrapper) moves to the alknet rewrite, where
its ancestor work (ADR-090, quinn-proxy POC) already lives. No local
binds except the two explicit, optional, feature-gated assembly shapes
(producer's local backend; consumer's front door).
### The recommended approach (validated pieces → crate shape)
- **Protocol base: fast-socks5's explicit typestate API** — the
`T: AsyncRead + AsyncWrite + Unpin` genericity carries both
substrates (POC #1: a channels `BiStream` drops in directly; the
local backend would feed a `TcpStream`). Drive the typestate
directly for UDP ASSOCIATE (`run_udp_proxy_custom` is unusable —
unconditional kernel bind; file the upstream ask).
- **Wire shape: the handle swap** (POC #2-validated) — one ALPN
(`alk/socks5`), one open op (`channels/socks5/sub`), trivial
params; the RFC conversation is the data plane; CONNECT =
`pump_bidi` pass-through post-reply (POC #1); ASSOCIATE = the
`select!`-driven datagram stage over `[len: u16 BE][addr + data]`
frames (alktunnels' length codec; RSV/FRAG only at front doors;
channel EOF ends the association; per-datagram addressing, no flow
table).
- **Egress seam: the dial-callback shape, extended** — `DialFn` for
CONNECT (POC #1's command-read interception point) +
`EgressFactory` for ASSOCIATE (POC #2), both function-not-trait,
both returning boxed halves. Local-socket and composed
(per-target alktunnels udp-tunnels) providers are assembly-layer
policies; the composed variant's per-destination open cost is
measured (~0.8 ms) and its per-target-ACL-for-free benefit is
validated.
- **Auth: "both," split by the door** (OQ-SK-02's settled posture) —
producer side: the alkcall identity seam only (the in-band
conversation runs NoAuth); the consumer's front door owns RFC 1929
for vanilla clients and maps credentials to the channel identity.
- **Layering: the alktty/alktunnels precedents** — substrate-agnostic
protocol modules at the crate core; feature-gated backend modules
(local binds) never imported by the protocol layer; producer +
consumer + params modules mirroring POC #1/#2's structure; the
fork posture for fast-socks5 is vendoring the `T`-generic subset
into this crate gated behind a `net`-shaped feature (POC #4:
~120 cfg lines, wasm-clean subset functionally complete).
- **Open for Phase 1 ADRs** (inputs collected, decisions pending):
the demux-vs-phase extensibility trade (evidence now favors the
phase model — no in-band control vocabulary was needed across both
POCs); the params-format/ALPN naming ADRs (wire-stable once
published, no consumers exist yet); `SOCKS5_OPEN_SCOPE` convention
(must distinguish BIND from CONNECT/ASSOCIATE — OQ-SK-08's security
note); the door's credential→identity mapping shape; the wasm
decision (Case 1 vs Case 2) once the adapter story is confirmed;
BIND's base-crate inclusion + default accept variant (OQ-SK-08);
the proxy-workload channel-cap recommendation (OQ-SK-09); the
virtual-subnet translation-layer shape (OQ-SK-05 addendum).
- **Upstream asks (we own both upstreams; file early, land there):**
fast-socks5 — the `net` feature gate (POC #4's ~120-line change)
and the `run_udp_proxy_custom` unconditional-bind seam. alktunnels —
listen-addr inspection without consuming an accept (OQ-SK-08
variant 2's blocker). First-real-consumer asks, the alkcall
E-01/E-02 precedent.
### What Phase 0 could NOT answer (handed to Phase 1)
- The datagram-framing ADR (the one-way door) — now has empirical
ground: framing validated, LRU/dedup semantics pinned, open cost
measured. Decide with the first consumer in sight.
- The wasm root question (OQ-SK-04) — mechanism de-risked; the
adapter-story input is a product decision, not a research one.
- Target-policy shape (OQ-SK-05) — the dialer-refuses shape is
evidence-backed; the declarative-allowlist variant stays a Phase 1
choice. (The 2026-09-14 virtual-subnet proposal is recorded there —
dial-callback translation, no wire change.)
- Channel capacity for proxy workloads (OQ-SK-09) — wire change
rejected; assembly-layer config + a documented proxy-workload cap
recommendation stay Phase 1 decisions. In-channel port
multiplexing considered and rejected there.
- BIND — RFC-refused in both POCs; **a use case and a mechanism were
found in the 2026-09-14 findings review** — OQ-SK-08 records the
gap, the accept-side-resource shape (mirroring alktunnels' `-R`
far-side listener), the address-first-vs-accept-first design
question, and POC #5. Deciding whether BIND ships in the base
crate (and which accept variant is default) is a Phase 1 ADR —
support is additive (old consumers still get the RFC refusal), so
nothing here is a one-way door.