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).
1216 lines
69 KiB
Markdown
1216 lines
69 KiB
Markdown
---
|
||
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 1–2 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. |