The Phase 0 doc had accumulated stratified updates (each revision layered on top of the prior text), leaving duplicated statements, superseded reasoning presented as live, and a few internal conflicts. This rewrite is a consolidation only — no decisions changed: - Each OQ opens with a status line; resolved content stated as settled fact; superseded reasoning retained only where it carries Phase 1 ADR weight (e.g. OQ-SK-04's decision tree as the reasoning trail). - The inline-vendor decision now stated once (OQ-SK-04 + the Convergence Layering bullet) instead of ~10 restatements; stale 'file the upstream ask' residue removed (Convergence protocol-base bullet). - The component-3 transfer to alknet stated once in §Vision; the noq section is background-only; OQ-SK-06 compressed to its resolution. - OQ-SK-01/03's duplicated direct-op update merged into its OQ; OQ-SK-08's superseded sequencing consistently framed as the retired POC workaround against alktunnels 0.1.0. - Checklist: OQ-SK-03/06/07 and the POC box checked to match their own text; 'RFC-refused in both POCs' corrected (fast-socks5 refuses BIND; POC #5 implemented it). - Preserved: all external anchors cross-referenced by the findings files (§OQ-SK-07 register + numbering, §Prior art's run_udp_proxy_custom caveat, OQ-SK-04's decision record, the Convergence Layering bullet), every ADR/API/file:line reference, POC numbers, and measurement figures. Restored on audit: the 65507 u16-suffices rationale, skip_auth_this_is_not_rfc_compliant, lib.rs:91, the wasm-resolver interaction note. Verified: anchor audit against all cross-referencing docs (no dangling references), content-loss audit vs the prior version (no unsuperseded fact dropped), fast-socks5 v1.0.0 spot-checks (TCPBind refusal sites, wait_on_tcp:1195, private new_reply, typestate methods — all true).
66 KiB
status: draft last_updated: 2026-09-20 (consolidation rewrite: current-state form, one canonical statement per decision, reasoning trails trimmed to their Phase 1 weight; no decisions changed)
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
the converged recommendation. 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.
Phase 0 converged 2026-09-14 and is POC-complete: every numbered POC on the register either passed or was dropped with the scope decision (§OQ-SK-07). The remaining open questions are Phase 1 ADR decisions, each with its inputs collected (§What Phase 0 could NOT answer).
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, carrying the RFC conversation inside a channels data channel like any other produced resource. The RFC 1928 implementation is the vendored fast-socks5 subset (the inline vendor, §Layering) — the crate owns the protocol code in-tree.
Scope (resolved 2026-09-14, post-POC): socks + channels, nothing else. The original three-component split serves different purposes and was at risk of being conflated; two of the three are this crate:
-
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 —Moved to the alknet rewrite — it is alknet's own ancestor (ADR-090 + the quinn-proxy POC live there), it neither produces nor consumes channels, and alknet is where downstream clients will actually consume it. The transfer took OQ-SK-06 and POC #3 with it. This crate's client-side story is the channels-native consumer session (POC #1) plus the wrapper-aware associate session (POC #2) and the optional front doors — both POC-validated.AsyncUdpSocketimplementations (quinn and noq) so downstream clients can route QUIC through any third-party SOCKS5 server (the iroh privacy posture: a client that doesn't want to leak its real IP to relays needs a SOCKS hop ahead of the QUIC dial).
Components 1+2 form the producer/consumer protocol pair (channels in, RFC 1928 inside). 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. - The protocol layer is substrate-agnostic and generic over the
stream. The vendored fast-socks5 typestate 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 (producer half only). The server
side is the
-Dcapability alktunnels deferred; the client side (alknet ADR-090'sSocks5UdpSocket, the quinn-proxy POC) stays in alknet per the scope resolution above. The crates share conclusions, not code paths. - 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 + amendments): 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 establisher can project reply fields (alkcall 0.8.0,Establishment::with_reply_field(s), ADR-049 amendment 3; consumable viaChannelClient::open_channel_with_reply) — the carrier alktunnels' bind-firstboundaddress rides; a SOCKS5 establisher that ever needs to return open-time metadata has the same seam (additive; absent fields leave the reply byte-identical). - 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 applies per hop, and a SOCKS5 resource is exactly the "further
downstream" shape alktunnels described. No socks5-specific relay
work. The relay machinery is in-tree — alkcall 0.8.0 ships
ChannelRelay+HubLegTemplate(ADR-051), so a hub leg is an install-template composition, not assembly-layer hand-rolling; relayed replies carry extra fields through per-hop-truthfully (the bind-firstboundflows end-to-end), and flavor-form ops (the direct op'schannels/tunnel/directshape) re-produce as channels, not forwarding stubs. - The identity seam — alkcall's CF-005/CF-006 (0.7.0): 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 — settled posture: split by the door). Still current at alkcall 0.8.0 — the 0.8.0 additions (reply projection, relay) are additive and do not touch it.
- 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). The vendored fast-socks5 subset is wasm-clean: the inline vendor gates kernel-net behind a default-onnet-shaped feature (OQ-SK-04 resolved; POC #4 verified the subset is functionally complete). - The upstream substrate this crate composes against — alkcall
0.8.0 (reply projection;
ChannelRelay/HubLegTemplateper ADR-051; flavor-form open-op ids in discovery per ADR-047 amendment 3) and alktunnels 0.2.0 (the direct opchannels/tunnel/direct, ADR-007; the bind-first establisher + forwarded opchannels/tunnel/forwarded, ADR-008;TunnelListener). The graduation (alktunnels docs/research/tunnels-graduation.md) took this crate's asks as evidence and landed them in one version bump — see the per-OQ notes (OQ-SK-01, OQ-SK-03, OQ-SK-08) for what changed in shape.
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, resolved shape below).
fast-socks5 — the vendored implementation
/workspace/fast-socks5 (v1.0.0, MIT — third-party upstream, not ours;
read-only reference checkout). The posture is the inline vendor
(OQ-SK-04, resolved 2026-09-20): vendor the T-generic subset into this
crate behind a default-on net-shaped feature, carrying the wrapper's
needs in-tree. /workspace/fast-socks5 remains the differential-testing
reference for RFC edge cases (reply-code mapping, domain addressing,
fragmentation); note the vendor point (v1.0.0) in the vendored code. 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. Caveat (verified 2026-09-13): the "custom" seam is narrower than it looks — it callsudp_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). Under the inline vendor this is a change we make in-tree, not an upstream ask: make the bind optional or accept a caller-supplied socket.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).- What the vendor must add (the four carried needs): the
kernel-net feature gate (POC #4: ~120 cfg lines, wasm-clean subset
functionally complete); BIND (absent upstream — fast-socks5
parses
Socks5Command::TCPBindbut every handler refuses it,lib.rs:384-386/server.rs:813-816; the wrapper drives the typestate itself); therun_udp_proxy_custombind seam (above); and the publication ofnew_reply(the reply encoder is private; the BIND reply#2 writer otherwise hand-rolls the 10-byte reply —poc-bind-findings.md).
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
Datagrams (UDP ASSOCIATE); producer-side dial + allow-list
(EndpointIds), 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. Adopted as the POC #2 design baseline; POC #2 validated it. - Why it is not the base: iroh types are load-bearing in the data
plane (
SendStream/RecvStreamin relay signatures; no genericTseam), 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 work belongs to the alknet rewrite (the 2026-09-14 scope
decision, §Vision): the client wrapper is alknet's ancestor-shaped
component — it neither produces nor consumes channels, and its open
research (the noq trait-shape split, OQ-SK-06, §OQ-SK-06) moves to
alknet's Phase 0. The alknet-side cleanup that applies there: the
hand-rolled codec in socks5.rs inlines the greeting/auth/CONNECT/
ASSOCIATE byte logic twice — fast-socks5's client types and
new_udp_header/parse_udp_request supersede it.
noq — the quinn fork the client story must also fit
Transferred to the alknet rewrite (2026-09-14, the scope decision, §Vision). Retained as background research for alknet's Phase 0; see OQ-SK-06 for the transfer rationale.
/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, transferred).
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 vendored crate provides the codecs — no 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 at the front door (OQ-SK-02).
Open Questions
These are the design questions Phase 0 resolved (or explicitly
deferred) 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. Status is stated per question; what
remains open is named explicitly. The point of this document is to hold
the reasoning without forcing premature decisions.
OQ-SK-01: Producer dial policy — what dials the target?
Status: shape resolved empirically (POC #1, 2026-09-13); the A/B
assembly-layer choice is a Phase 1 policy decision. The dial callback
exists and is called in-stream at the command-read interception point:
an injected DialFn(target) -> boxed halves closure (function-not-trait,
the alktunnels ADR-004 shape), returning TargetHandle { read, write }
(boxed halves, + Sync, satisfies the ChannelPlan payload bound).
Per-target refusal is an RFC reply code, not an establishment failure;
session-level refusal stays channels-level (channel:open_failed). The
no-trait hunch is evidence-backed; EgressFactory (OQ-SK-03) and
BindAcceptFactory (OQ-SK-08) extend the same shape to the datagram and
accept planes.
What remains open is where the callback comes from (the dial policy):
- Option A: dial via alktunnels (composition) — the SOCKS5 handler
opens an
alk/tunnelchannel naming the target, and the two-pump data plane is tunnel-channel ↔ SOCKS5-BiStream. Since alktunnels 0.2.0 (ADR-007) the dynamic-target channel exists — the direct op (channels/tunnel/direct,{substrate, target}params, the sshdirect-tcpipshape,tunnel:directas a separate ACL grant) — so Option A no longer needs a pre-registered per-target resource: one direct-op channel per CONNECT target (what the direct op was graduated for). The udp substrate rides the same op (one op, both substrates — OQ-SK-03's composed egress uses it too). - 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 with alktunnels and local-TCP implementations behind features. The POC evidence stands against the trait (two real implementations exist as callbacks, not trait impls; alktunnels OQ-TN-05's no-trait resolution holds).
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. The A/B choice
is an assembly-layer policy question; POC #5's scope-gate finding
(socks5:bind ≠ socks5:open) plus ADR-007's own rationale
("dial postgres-primary" vs "dial anything" are different grants)
both point the same way: separation lives at the ACL, not in the
protocol. The dialer-trait-vs-callback half of this OQ is closed; the
Phase 1 ADR picks the default provider set and the params shape for the
composed path.
OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth
Status: settled (2026-09-13): "both," split by the door. The
open-op hooks receive the per-call opener identity (alkcall CF-005/
CF-006); the RFC conversation also carries its own optional
username/password auth (RFC 1929). The settled posture: both mechanisms
are real, each on its own side of the door — the consumer's optional
front door (component 2's ssh -D shape, where the vanilla world meets
the ACL world; the vpn-like endgame composition points tun2proxy's
user:pass at it).
- 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 (fast-socks5's
skip_auth_this_is_not_rfc_compliant, or a no-op NoAuth method). 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 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). No fast-socks5 AuthMethod impl is needed — the door
checks credentials itself before handing the stream to the typestate
(the door is this crate's code; the trait was designed for embedders
who don't own the accept loop). The 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.
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).
OQ-SK-03: UDP ASSOCIATE without binding — the hard case
Status: baseline settled (2026-09-13, the handle swap) and
POC-validated end to end (POC #2, 2026-09-14 —
poc-udp-associate-findings.md). The datagram-framing ADR (the
one-way door) is Phase 1's, with the first consumer in sight; the
framing has empirical ground under it.
The handle swap — "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 (the iroh-socks5 cut: boundary-preserving, no RFC header on the tunnel wire, no producer-side flow table; RFC codec at front doors only). The length prefix is u16 BE — 65507 (the max UDP payload) < 65535, so u16 suffices; boundary preservation is mandatory (empty datagram ≠ EOF — alktunnels F-2's lesson transfers directly; the frame shape meets it structurally); - 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. The reply's BND.ADDR/
BND.PORT on the in-band path are pure RFC-compatibility surface (the
POC used the sentinel 127.0.0.1:0); only the front door reports a
real address.
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? Both are dial-callback policy
(OQ-SK-01's shape); the protocol layer sees neither.
- Local egress — the handler binds a real
UdpSocketandsend_tos per datagram (iroh-socks5'srelay_udp_serverbehind this crate'slocalfeature). No flow table, native-only. POC #2 implemented it (LocalEgress); domain-form destinations resolve at the egress (producer-side DNS — the ssh-Dsemantic, POC-resolved as "the egress owns resolution"). - Composed egress — the dial callback opens an alktunnels
udp-substrate channel per destination: a lazy destination→tunnel
table (open on first datagram, LRU-evict; POC #2 implemented it as
ComposedEgress). The flow table is reborn as channel handles — because alktunnels' udp substrate is connected (one tunnel, one fixed target) while ASSOCIATE names a different destination per datagram. The composed form's vehicle since alktunnels 0.2.0 (ADR-007) is the direct op —open_direct+Substrate::Udpdials a real UDP target through the dynamic-target op (channels/tunnel/direct), pinned by alktunnels' own test (a real UDP echo through the establisher path); the table entries are direct-op channels, and the per-destination ACL question shifts to thetunnel:directgrant + the dialer's target policy (the ACL governs the capability, the dial callback governs per-destination checks). Cost: one open per destination, measured ~0.8 ms (POC #2, N=20 loopback) — fine for DNS- browsing; the 256-channel default cap bounds fan-out (OQ-SK-09). Benefit: per-target ACL for free — dissolving most of OQ-SK-05 for the composed path — and hub relaying is per-target terminate-and-re-produce.
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).
Phase model vs alktty-style demux (the extensibility trade — Phase 1
ADR input). An alternative shape surfaced: alktty's logical demux
(input/output/error/control sub-streams inside one channel, type byte
per chunk) applied to the SOCKS5 channel. 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; 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); the phase model needs no mode byte
(the reply itself synchronizes the switch — POC #2 confirmed post-reply
garbage is refused structurally). So the per-chunk type byte demuxes a
problem SOCKS5 doesn't have. What the demux genuinely buys: 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. Evidence after
both POCs favors the phase model — no in-band control vocabulary was
needed. Decide via ADR with the first consumer in sight.
OQ-SK-04: fast-socks5 wasm posture — RESOLVED: inline vendor
RESOLVED 2026-09-20: inline vendor (decided). Two facts settled it:
(1) the ownership premise was wrong — we do not own fast-socks5
(third-party upstream), so the "file asks / PR" legs of the old
decision tree lose their cheap path; and (2) the wrapper's needs go
beyond the wasm gate regardless — BIND is absent upstream (POC #5
proved the wrapper must drive it itself), and the two seam fixes
(run_udp_proxy_custom's unconditional bind; private new_reply) were
only "asks" under the ownership assumption. The change set (net-gate +
BIND + bind seam + new_reply) is exactly the shape POC #4 measured as
minimal (~120 cfg lines + additive additions in the T-generic
region), so the vendor pays for itself without leaning on the adapter
story. Mechanics in the Convergence §Layering bullet; the reasoning
trail below is retained for the record.
The decision tree (2026-09-12, both legs written under the wrong ownership premise "we own upstream"):
- 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).
Concretely "fork" means vendoring the relevant subset into this
crate (the codecs, typestate machinery, client
use_streampath — the parts that are alreadyT-generic) and gating the native-net pieces behind the crate's ownlocalfeature. (The vendoring shape is what was adopted — the PR is optional courtesy, and "shrinks to a plain dependency" is moot since the vendor also carries BIND + the seam fixes upstream lacks.) - Case 2: wasm doesn't matter → plain dependency, no fork. Ride the
router.rsnative path exactly as-is (the alkhttp precedent — a crate in this suite can ship native-only). Strictly better if its premise holds — a wrap-only relationship with no carried changes. (Superseded: the premise was falsified by the BIND + seam needs.)
The root question was whether wasm makes sense for alksocks; the
analysis on file: what wasm would serve is the protocol layer
(components 1+2) 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; no local sockets needed client-side). A wasm producer is
odd standalone (no kernel egress) but composes — its dialer can open
alktunnels channels further downstream (the hub story). What wasm would
not serve: the quinn/noq client wrapper (since transferred to alknet) —
native-only in practice. The alkhttp contrast, honestly weighed:
alkhttp's substance is socket-native so nobody misses wasm there;
alksocks differs structurally — its protocol substance is byte-framing
over a generic T — but "costs nothing under the fork" is only an
argument once Case 1 is chosen; the use case is a planned sandboxed
SOCKS consumer. (Retrospective, 2026-09-20: the ownership correction
resolved the OQ ahead of the adapter story — the vendor is justified by
BIND + the seam fixes alone. The sandboxed-wasm-consumer adapter story
remains a product input for how much wasm surface to exercise, not a
gate.)
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: for the composed UDP egress path, per-target ACL comes free — each destination rides its own direct-op channel governed by the tunnel grant's ACL, OQ-SK-03 variant 2):
- 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, evidence-backed); 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. POC-validated in shape (socks5:open,socks5:bind); the convention ADR is Phase 1's. - Domain-form targets (RFC 1928 ATYP 0x03): resolve producer-side
(fast-socks5's
dns_resolveconfig), refuse, or pass through to the dialer unresolved? POC #2 resolved the local-egress half ("the egress owns resolution"); the general dialer-pass-through posture is the remaining Phase 1 choice (wasm producers may not have a resolver at all — an interaction with the vendored crate's wasm posture, OQ-SK-04 resolved 2026-09-20).
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 variant 2, the bind-firstboundreply field). 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), soconnect(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 — RESOLVED by transfer
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 (the noq trait-shape comparison: create_sender() -> Pin<Box<dyn UdpSender>> replacing create_io_poller + try_send, the
Box<dyn AsyncUdpSocket> constructor ownership change; the dependency
posture — noq is consumed from the n0 workspace/git, not crates.io; the
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 (POC #3) is dropped from this
crate's register accordingly. The noq surface notes are retained under
§Prior art as alknet-facing background.
OQ-SK-07: POC register (closed)
All POCs resolved; the register is closed for this crate's Phase 0. Numbering is historical (do not renumber — findings files and commit messages reference these numbers):
| # | What | Status | Where |
|---|---|---|---|
| 1 | Channels-native SOCKS5 CONNECT | Passed 2026-09-13 | poc-connect-wasm-findings.md |
| 2 | UDP ASSOCIATE (handle-swap shape) | Passed 2026-09-14 | poc-udp-associate-findings.md |
| 3 | noq AsyncUdpSocket impl |
Dropped 2026-09-14 — transferred to alknet with OQ-SK-06 | — |
| 4 | fast-socks5 wasm fork-minimality | Passed 2026-09-13 — folded into #1's worktree, shares #1's findings file | poc-connect-wasm-findings.md §POC #4 |
| 5 | BIND (accept-side command) | Passed 2026-09-15 | poc-bind-findings.md |
Highlights (details in the findings files):
- POC #1 validated the
BiStream-as-Tgenericity claim,register_openable_with_establisherfit,pump_bidias the data plane, the dial-at-command-read layering, the identity seam end to end, typed refusal paths (session-level at the registry ACL; target-level as RFC reply codes), and the curl front door. - POC #4 measured the fast-socks5 wasm gating at ~120 cfg lines
across 5 files; the wasm-clean subset is the functional protocol
subset (a full RFC 1928 CONNECT conversation passes
--no-default-features). - POC #2 validated the handle-swap baseline end to end (both
egress variants; composed per-destination open cost ~0.8 ms;
front-door RFC-codec composition) and produced the load-bearing API
findings: the datagram stage is
select!-driven with the egress readiness contract (next_replymust bePendingwhen no reply is ready — an instantly-readyOk(None)starves a biased select into livelock), framing applied exactly once by the wire layer (inner bytes at the egress seam), and the egress/session ownership must ride the table entry (teardown-at-birth). - POC #5 validated the BIND phase model end to end (both accept
variants; the
socks5:bindscope gate; failure mapping RFC-faithful; the vanilla front door for BIND), pinned the composed-variant sequencing against alktunnels 0.1.0 (superseded in shape by the bind-first graduation — see OQ-SK-08), and surfaced the ownership-rides-the-handle lesson as the third witness of the teardown-at-birth pattern.
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
Status: use case found, POC-validated (POC #5, 2026-09-15); the composed path composes against alktunnels 0.2.0 (the graduation superseded the ask ledger's shape); the ship-in-base-crate and default accept variant are Phase 1 ADRs.
The gap (fast-socks5): Socks5Command::TCPBind is parsed
(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, carried in the vendor (no upstream ask). POC-confirmed:
reply_success's sock_addr parameter carries reply#1's BND.ADDR
verbatim; the courtesy item is publishing new_reply (rides the
vendor — see §Prior art's four carried needs).
The shape (convergent precedent: iroh-socks5's channels-native BIND —
server relays raw bytes post-reply for CONNECT/BIND, docs/research/ iroh-socks5-eval.md): 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 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):
- 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. - Composed (alktunnels listen resource) — the listener lives on a listen resource (per-listener ACL for free, hub-relayable per the terminate-and-re-produce story). Superseded in shape (2026-09-20): the ask ledger landed via the bind-first/forwarded graduation — see below.
- 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.
POC #5's resolution (2026-09-15 — poc-bind-findings.md): variants
1 and 2 both implemented and passing. The hunch (variant 1 as the
faithful + zero-ask default) was right. Against alktunnels 0.1.0 the
composed variant needed a pinned workaround — reply#1 first, the
tunnel open rides the accept future (the accept-first establisher
could not open before reply#1 without deadlocking into the
establishment timeout) — plus a two-item ask ledger (learn the bound
address without consuming an accept; peer metadata on accepted
handles).
The graduation landed — superseded in shape (2026-09-20, alkcall
0.8.0 + alktunnels 0.2.0). The graduation (alktunnels
docs/research/tunnels-graduation.md → ADRs 007/008) took the ledger's
two asks as evidence and adopted the ssh forwarded-tcpip-shaped
alternative over the per-ask carriers:
- Ask #1 (learn the bound address without consuming an accept) is
dissolved, not implemented. The bind-first establisher
(
bindfirst_establisher, ADR-008 §1) binds during the establishment phase — fast, no app-server dependency — so the open-op reply natively carries the real OS-chosen bound address (theboundreply field, projected via alkcall 0.8.0'sEstablishment::with_reply_field, ADR-049 amendment 3; consumed viaopen_channel_with_reply/TunnelListener::bound()). There is no accept to avoid consuming: establishment = bind. This replaces the pinned "reply#1 first, the tunnel open rides the accept future" workaround (it existed for the accept-first establisher) and dissolves POC #5's establishment-bound finding: the accept wait now lives in the teardown-governed pump phase, so FTP-length BIND waits stop resolving as spurious 10s-establishment refusals (ADR-008's stated second win). The old accept-first surface (listen_establisher+AcceptQueue) stays in 0.2.0 for the accept-as-open shape. - Ask #2 (peer metadata on accepted handles) landed at the wire's
native moment. The forwarded op (
channels/tunnel/forwarded, ADR-008 §3) carries{listen, substrate, peer}params per accept — the peer address rides the open-op params (deny-unknown, schema-gated) rather than aTargetHandlefield;ForwardedOpener+make_forwarded_op_handlerare the serving side. Correlation is a producer-minted u32 token (ADR-008 Amendment 1, 2026-09-18: thelistenreply field + forwarded param), not the originally-planned channel-ID key.
Consequence for variant 2: the composed BIND path is now fully
vanilla-fidelity with no interim stand-ins — the factory no longer
carries an expected bind_addr, reply#1 advertises the real bound,
reply#2 carries the real peer, and no establishment bound threatens
long BIND waits. POC #5's findings remain accurate as the record of
what was validated against alktunnels 0.1.0; the Phase 1 shape composes
against 0.2.0 (register_tunnel_bindfirst_openable +
BindFirstInjections, or TunnelListener on the opener side).
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. POC-validated (socks5:bind checked at command dispatch
from the per-call opener identity; ConnectionNotAllowed (0x02)
refusal while CONNECT still serves the same identity).
OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
Status: analysis complete (2026-09-14); the documented proxy-workload recommendation is Phase 1's. 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 spends a downstream channel per destination; BIND spends an accept resource per listener).
- No wire change — rejected permanently: the caps are policy knobs
(configurable without a wire change; the
u32channel-ID wire space is ~4 billion). The resolution is config, not wire. - Assembly-layer config is the mechanism (ADR-040/041): 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: 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. (Third-party upstream; the inline vendor — OQ-SK-04, resolved 2026-09-20 — vendors theT-generic subset into this crate; this checkout is the differential-testing reference.) - iroh-socks5 —
crates.io/crates/iroh-socks5v0.5.0, repogithub.com/mattgeddes/iroh-socks5(crates.io has no repository link; the published source matches). Evaluated indocs/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-Dcomposition conclusion, hub-owns-the-connection, the UDP codec decision + F-2),docs/research/tunnels-graduation.md(the direct op + bind-first/forwarded graduation — the substrate this crate composes against),docs/architecture/(params/ALPN/ACL ADR template), POC summaries (POC placement and findings conventions). - alkcall —
docs/architecture/decisions/ADR-037/039/047/049/050/051 (channel ops, params-is-ALPN-specific, establishment, pump_bidi, reply projection, hub relay), 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). (Research transferred to the alknet rewrite with OQ-SK-06 — retained here as alknet-facing background.) - 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 (transferred to the alknet rewrite); also the privacy motivation that originally motivated 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 component split and the scope resolution)
- 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); ownership premise corrected 2026-09-20 (fast-socks5 is third-party upstream; the posture is the inline vendor, OQ-SK-04 resolved) - OQ-SK-01 (dial policy) — shape resolved empirically (POC #1: dial callback at the command-read interception point, boxed halves, no trait); the A/B assembly-layer choice is a Phase 1 policy decision
- OQ-SK-02 (auth mapping) — settled 2026-09-13 ("both," split by the door; POC #1 validated the seam end to end); Phase 1 formalizes the door's credential→identity mapping
- OQ-SK-03 (UDP ASSOCIATE) — the handle-swap baseline settled 2026-09-13 and POC-validated end to end (POC #2, 2026-09-14); the datagram framing is wire-stable once published — the Phase 1 ADR (framing + demux-vs-phase) remains, with empirical ground
- OQ-SK-04 (fast-socks5 wasm) — resolved 2026-09-20: the inline vendor (the ownership premise was corrected — we don't own fast-socks5; BIND + the seam fixes must be carried regardless, so the vendor pays for itself independent of the adapter story; POC #4 de-risked the mechanism)
- OQ-SK-05 (target policy) — dialer-refuses shape evidence-backed;
per-target-ACL-for-free validated for the composed egress path
(POC #2); scope-gate convention (
socks5:open/socks5:bind) POC-validated in shape; the allowlist variant, exact scope convention, and the 2026-09-14 virtual-subnet proposal are Phase 1 decisions - OQ-SK-06 (noq client) — resolved by the scope decision (2026-09-14): transferred to the alknet rewrite; this crate's channels-native client story is POC-complete without it
- OQ-SK-07 (POC register) — closed: #1, #2, #4, #5 passed; #3
dropped with the scope decision; findings in
docs/research/ - OQ-SK-08 (BIND) — use case + mechanism found; POC #5 validated the shape end to end; the ask ledger landed 2026-09-18/20 (superseded in shape by the bind-first/forwarded graduation); the remaining Phase 1 ADR (ship in base crate? default accept variant?) has empirical ground under it
- OQ-SK-09 (channel capacity) — 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
- Targeted POCs run + summaries in
docs/research/— complete; no pending POCs remain for this crate's Phase 0 - 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 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: the vendored fast-socks5 explicit typestate API —
the
T: AsyncRead + AsyncWrite + Unpingenericity carries both substrates (POC #1: a channelsBiStreamdrops in directly; the local backend feeds aTcpStream). Drive the typestate directly for UDP ASSOCIATE (run_udp_proxy_customis unusable on channels — unconditional kernel bind; the vendor fixes the seam in-tree). - 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_bidipass-through post-reply (POC #1); ASSOCIATE = theselect!-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); BIND = the phase model extended (POC #5). - Egress/accept seams: the dial-callback shape, extended —
DialFnfor CONNECT (POC #1's command-read interception point),EgressFactoryfor ASSOCIATE (POC #2), andBindAcceptFactoryfor BIND (POC #5): all function-not-trait, all returning boxed halves. Local-socket and composed 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. The composed providers compose against alktunnels 0.2.0 — the direct op (channels/tunnel/direct, ADR-007) is the dynamic-target channel for CONNECT, andopen_direct+Substrate::Udpis the composed ASSOCIATE vehicle (both substrates, one op, one grant:tunnel:direct); the hub-leg for relaying them is alkcall 0.8.0's in-treeChannelRelay/HubLegTemplate(ADR-051). - 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
fast-socks5 posture is the inline vendor (OQ-SK-04, resolved
2026-09-20): vendor the
T-generic subset into this crate (the codecs, typestate machinery, clientuse_streampath), gated behind a default-onnet-shaped feature (POC #4: ~120 cfg lines, wasm-clean subset functionally complete) — and carry the four wrapper needs in the same vendor: BIND (absent upstream; POC #5's typestate-driven shape), therun_udp_proxy_custombind seam, thenew_replypublication, and the wasm gate./workspace/fast-socks5stays the differential-testing reference; note the vendor point (v1.0.0) in the vendored code. - 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 datagram-framing ADR (the one-way door — decide with the first
consumer in sight); the params-format/ALPN naming ADRs (wire-stable
once published, no consumers exist yet);
SOCKS5_OPEN_SCOPEconvention (must distinguish BIND from CONNECT/ASSOCIATE — OQ-SK-08's security note); the door's credential→identity mapping shape (OQ-SK-02); the A/B egress provider default (OQ-SK-01); BIND's base-crate inclusion + default accept variant (OQ-SK-08); the proxy-workload channel-cap recommendation (OQ-SK-09); target-policy shape + the virtual-subnet translation-layer shape (OQ-SK-05). The wasm decision itself is settled; only the sandboxed-wasm-consumer adapter story remains a product input, and nothing waits on it.
What Phase 0 could NOT answer (handed to Phase 1)
- The datagram-framing ADR (the one-way door) — empirical ground in hand: framing validated, LRU/dedup semantics pinned, open cost measured. Decide with the first consumer in sight.
- 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.
- BIND — validated end to end (POC #5); the graduation supplied the composed variant's full-fidelity substrate (alktunnels 0.2.0's bind-first establisher + forwarded op). 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.
- The door's credential→identity mapping shape (OQ-SK-02) and the scope-string convention — Phase 1 ADRs with POC ground under them.