- phase-0 vision: components split — (1) producer half (expose the SOCKS5 service over channels), (2) consumer half (consume it, optionally bind it locally — the ssh -D front door + tun2proxy path), (3) standalone SOCKS client wrapper for quinn/noq that must work with any RFC 1928 server (iroh relay/peer IP-leak motivation, alknet ADR-090 generalized); the earlier docs conflated the consumer-side bind with the producer-side local backend - principle 6: the vpn-like endgame — tunnel a produced SOCKS5 resource to a local port, point tun2proxy (--proxy socks5:// natively) at it; verified tun2proxy src/args.rs - OQ-SK-04 resolved as fork-first: 'fork' = vendor-and-vet inline in the crate (minimal diff, feature-gate native-net), PR upstream as courtesy, never wait on approval; wasm-drop (alkhttp precedent) is the escape hatch, not the plan - AGENTS.md conventions 4/9/17 aligned (fork-first, binds on either side, wasm posture)
38 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-09-12 |
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):
- 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
-Dservice over channels. - 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:portendpoint and relay each accepted connection into a channel (the ssh-Dlocal-exposure shape, and the path tun2proxy/curl/browser point at). - SOCKS client wrapper for QUIC runtimes —
AsyncUdpSocketimplementations (quinn and noq) so downstream clients (alknet) can route QUIC through any third-party SOCKS5 server — not just one produced by this crate. The motivating case is iroh's privacy posture: iroh relays see the real IP (intended for relay-assisted p2p), and a client that doesn't want to leak it needs a SOCKS hop ahead of the QUIC dial. This component is a plain client library (the alknet ADR-090 descendant); it neither produces nor consumes channels.
Components 1+2 form the producer/consumer protocol pair (channels in, RFC 1928 inside); component 3 is standalone and composes with any RFC 1928 server. Keep them structurally separate in the crate layout.
Guiding principles, inherited from the alk* family:
- "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
-Dfront door). The protocol itself never binds; the binding decision belongs to the caller. - The
-Dconclusion, realized. alktunnels' Phase 0 settled that-Dcomposes at the assembly layer: "-Dis 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. - Wrap
fast-socks5, preserve its genericity.fast-socks5's explicit typestate server API (Socks5ServerProtocol<T, states::*>) is generic overT: AsyncRead + AsyncWrite + Unpin, and its interception points (run_tcp_proxy,run_udp_proxy_custom,transfer) accept any suchT. The channels adapter feeds the state machine aBiStream; a local backend feeds it aTcpStream. The wrapper must not leak either substrate into the protocol layer (alkttyTtyBackend/ alktunnels pump-halves inversion-point precedent). - 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. - Extract-and-improve from alknet. alknet's SOCKS5 story is the
direct ancestor: the client side (
alknet-client/src/socks5.rs, ADR-090 —Socks5UdpSocketimplementsquinn::AsyncUdpSocketso QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the server side (the-Dcapability alktunnels deferred). This crate rehomes both halves behind one protocol crate and improves them. - 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:portnatively,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 oneBiStreamper 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 typedchannel:open_failedcall 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 theBiStream— 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 viaChannelClient(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-socks5itself 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.
-Dcomposes 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 overT: AsyncRead + AsyncWrite + Unpin. Flow:start(inner)→negotiate_auth(&methods)→finish_auth()/accept_no_auth/accept_password_auth→read_command()→ (reply_success,reply_error). The legacySocks5Server/Socks5Socket/IncomingAPI (binds aTcpListener) 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);StandardAuthenticationenum 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, thentransfer(inbound, outbound)(acopy_bidirectionalwrapper, itself generic). This is where a channels-native dial replaces thetokio::netdial: the wrapper's producer half intercepts here, dials via alktunnels/alkcall primitives (or its own dial policy), and returns theTback.run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)— the customizable UDP ASSOCIATE handler: the wrapper supplies a customtransferclosure that owns the relay half (OQ-SK-03).transfer(inbound, outbound)— plain two-pump copy; a channelsBiStreamis a legalTon 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 theSocket2-based relay machinery. The defaultrun_udp_proxybinds 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_streamupgrades anyAsyncRead + AsyncWrite + Unpinalready connected) andSocks5Datagram<S>(UDP associate; also accepts a caller-supplied socket viause_socket).Socks5StreamimplsAsyncRead + AsyncWriteitself, so a consumer session over a channelsBiStreamis the intended use, not a hack. Note:Socks5Stream::connectconvenience constructors dialtokio::net::TcpStreamand are non-wasm;use_streamis the substrate-free path. - Error surface —
ReplyError(the RFC reply codes,as_u8/from_u8) andSocksError;SocksServerErroron the explicit API. Map these faithfully; never collapse a refusal into a generic error. router.rsexample — 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).
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::AsyncUdpSocketexists (noq/src/runtime/mod.rs:44) but with a different shape than quinn 0.11:create_io_poller+try_sendare replaced bycreate_sender() -> Pin<Box<dyn UdpSender>>withpoll_send(transmit, cx)— a sender-object split (any number ofUdpSenders per socket, each holding its own waker).RecvMetagained fields (interface_index,timestamp) but stays default-constructible;Transmitis unchanged in the fields the SOCKS5 wrapper touches (destination,contents,ecn,src_ip,segment_size).Endpoint::new_with_abstract_socketexists (noq/src/endpoint.rs:162) with the same doc-comment intent, but takesBox<dyn AsyncUdpSocket>rather thanArc<dyn AsyncUdpSocket>— the poller-removal reshuffle also changed the ownership shape.- The alknet
Socks5UdpSocketdoes not drop in unchanged: the impl must be rewritten againstcreate_sender/poll_send, and theArc→Boxchange 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.rsinlines the greeting/auth/CONNECT/ASSOCIATE byte logic twice (handshake for UDP, again for CONNECT). fast-socks5's client types andnew_udp_header/parse_udp_requestsupersede 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/tunnelchannel 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
(
TcpStreambehind thelocalfeature, 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_compliantor 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
AuthMethodimplementation that consults the alkcallAuthContext/Identity(filed upstream if the hook shape doesn't fit — AGENTS.md convention 17)?
Hunch: identity-at-the-open-op is the primary gate for channels-native consumers; in-band username/password exists for the local-backend path only. Needs a decision (and possibly an upstream ask) in Phase 1.
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
BiStreamcarries length-prefixed datagrams (the alktunnels UDP codec shape,[len: u16 BE]per datagram; 65507 < 65535 so u16 suffices). The reply to the client rewritesBND.ADDR/BND.PORTto a sentinel that means "same channel" — but vanilla SOCKS5 clients will literallysendto()that address, 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). - 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
BiStreamsemantics 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.
- Same channel, extended protocol — after ASSOCIATE, the channel's
- What does
BND.ADDR/BND.PORTreply contain on the channels path? RFC says the relay address; a channels path has none. fast-socks5'srun_udp_proxy_customlets the wrapper supply the reply and the relay half — the seam exists upstream; the semantics are ours to define. - Per-datagram addressing — the SOCKS5 UDP header carries the
destination per datagram (ATYP + addr + port), so one association
multiplexes many endpoints: per-endpoint flow table producer-side
(the alknet
Socks5UdpSocketmodel, and the udpgw prior art alktunnels documented). Boundary preservation is mandatory (empty datagram ≠ EOF — alktunnels F-2's lesson transfers directly). - Do vanilla (wrapper-unaware) clients need channels-native UDP at all? If the only UDP ASSOCIATE consumers are wrapper-aware, the sentinel-reply shape is fine and no virtualized relay address is ever invented.
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, unchanged): wrapper-aware client halves ride the same-channel length-prefixed-datagram shape; vanilla clients get UDP ASSOCIATE only via the optional local backend. But this is 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. Options:
- Option A: upstream feature-gating ask — add a feature to
fast-socks5 that gates
tokio::net/socket2behind a default-onnetfeature (like alktty'slocal), leaving the codecs, typestate machinery, and clientuse_streampath wasm-clean. We own upstream (AGENTS.md convention 17); the alk* precedent is to make asks early. - Option B: protocol reimplementation — the wrapper reimplements
the SOCKS5 state machine over
tokio::iogenerics (RFC 1928 is small); fast-socks5 remains the native-path implementation. Duplicates protocol logic; against the "wrap, don't fork" vision unless A fails. - Option C: wasm drops off the invariant for this crate — accept a native-only default. Against the alktty/alktunnels precedent; document as an ADR-worthy exception if forced.
Action: verify the breakage empirically (a minimal crate depending on
fast-socks5, cargo check --target wasm32-unknown-unknown), then take
Option A upstream if confirmed. This OQ gates the wasm verification
command in AGENTS.md (the expected-failure note is already written
there).
Fork vs reimplement vs wrap — resolved as fork-first (2026-09-12
discussion, supersedes the earlier upstream-ask-first weighting). The
wrap surface was re-checked and no structural fork is needed for the
channels path: CONNECT rides the interception points as-is —
read_command → own dial → reply_success → transfer (the
examples/router.rs shape), with the producer's BiStream as T on
both sides of transfer. UDP is ours either way: run_udp_proxy_custom
takes a custom transfer closure for the relay half, reply_success
accepts any SocketAddr (the sentinel reply), and
new_udp_header/parse_udp_request are public, pure, and reusable.
The one hardwired piece — run_udp_proxy's peer relay socket
(udp_bind_random_port, socket2-based, called inside the default
handler before run_udp_proxy_custom even runs) — is in the default
handler, not the protocol: the wrapper's custom closure never needs it.
Decision: fork-first, "fork" = inline in this crate (vendor-and-vet).
Do the feature gating as a minimal, minimal-diff fork and use it —
offering it upstream as a PR (merge if they want it; we carry the fork
regardless). Rationale: depending on someone else's approval cadence is
the worst part of the upstream-ask path; the long-term cost of
maintaining a minimal fork of a well-written lib trends toward zero
(modern tooling/AI assistance makes small-diff rebase-and-review cheap);
and the changes needed here are genuinely small (feature-gate
tokio::net/socket2 behind a default-on net feature, per the
alktty local pattern). Concretely this likely means vendoring the
relevant subset into this crate (the codecs, typestate machinery,
client use_stream path — the parts that are already T-generic) and
gating the native-net pieces behind the crate's own local feature,
rather than publishing a divergent crate. fast-socks5 remains the
reference checkout for differential testing of RFC edge cases
(reply-code mapping, domain addressing, fragmentation) and the PR
source. If upstream accepts the feature-gating PR, the fork shrinks to
a plain dependency; if not, we carry it — either way this crate's
timeline is not hostage to approval. What is not off the table:
dropping wasm outright is still available (Option C) as the
alkhttp precedent shows a crate can ship native-only — but it is not
preferred; the fork makes the wasm-clean default achievable without
anyone's approval. (The user also flagged the alternative of simply
not worrying about wasm for this crate and riding the router.rs
native path; that remains viable if the fork's gating turns out more
invasive than expected — an escape hatch, not the plan.)
OQ-SK-05: Target policy and egress scoping
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:
- 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, alkttyTTY_OPEN_SCOPEshape) — 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_resolveconfig), refuse, or pass through to the dialer unresolved? (DNS-on-the-producer is the SSH-Dsemantic; wasm producers may not have a resolver at all — another OQ-SK-04 interaction.)
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
Socks5UdpSocketagainst noq (behind a feature likenoq), quinn (behindquinn), 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-runfor 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_fragmentdefault (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).
OQ-SK-07: POC scope for what remains unvalidated
Candidates, in rough priority order (per the SDD process's "validate promising approaches"):
- 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: theregister_openable_with_establisherfit,pump_bidias the data plane, params shape (trivial — probably no params), and theBiStream-as-Tgenericity claim. This is the crate's core value proposition and the cheapest to validate (alktunnels' forward POC is the template). - 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_requestover the length framing), the flow table, and empty-datagram handling (alktunnels F-2 transfers). - noq
AsyncUdpSocketimpl POC — the client-side story against noq 1.2: associate through a fast-socks5 server, wrap asnoq::AsyncUdpSocket, complete a QUIC handshake. Derisks OQ-SK-06 (thecreate_sender/Boxshape changes) before the client spec is written. (The quinn variant is already proven by the quinn-proxy POC — re-validating it here is optional.) - fast-socks5 wasm check — minimal crate,
cargo check --target wasm32-unknown-unknown, confirm/inflect OQ-SK-04. Cheap; can fold into #1's worktree.
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.
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. - alktunnels —
docs/research/phase-0-findings.md(the-Dcomposition 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_SCOPEscope-gating shape, feature-gatedlocalbackend, wasm-clean default-crate verification commands. - tun2proxy —
/workspace/tun2proxysrc/udpgw.rsand 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/irohiroh/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
- OQ-SK-01 (dial policy) — researched, half-answered (injected dialer hunch); decide in Phase 1 against the spec
- OQ-SK-02 (auth mapping) — posture drafted (identity seam primary, in-band auth for the local backend); decide in Phase 1
- 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
- OQ-SK-04 (fast-socks5 wasm) — verify empirically (POC #4); resolved posture: fork-first (vendor-and-vet, PR as courtesy) — carry the minimal fork regardless of upstream's decision; wasm-drop (alkhttp precedent) is the escape hatch, not the plan
- OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision; scope-gate convention pinned in Phase 1
- OQ-SK-06 (noq client) — research pass (trait-shape comparison, dependency posture) + POC #3 if the research is not decisive
- Targeted POC(s) run + summaries in
docs/research/(OQ-SK-07; #1 first — it validates the core value proposition) - Converge: recommended approach written up, ready to hand to the Architect for Phase 1