diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f61703 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +.worktrees/ +Cargo.lock \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eba6a18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,336 @@ +# AGENTS.md + +Operating instructions for opencode agents working in this repo. opencode +auto-loads this file as instructions, overriding the built-in defaults for +this project. Custom agents in `.opencode/agents/` inherit these rules +unless their own prompts say otherwise. + +## Git Workflow + +**Commit and push when reasonable.** When a change is complete and +verified (build + lint + tests pass), commit and push to `origin/main` +without asking. This overrides the built-in default of "only commit when +explicitly asked." + +The workflow: + +1. Make the change +2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check`, `cargo doc --no-deps` if docs changed +3. Inspect `git status` and `git diff` before staging — stage only the + intended files, never secrets +4. Write a concise commit message matching the repo style (see `git log + --oneline -10`). For multi-point changes, use a summary line plus a + body with bullet points and a verification block. +5. `git push origin main` +6. Report the commit hash and the verification summary + +Exceptions — **do not** commit or push without asking: + +- The change is exploratory / speculative (you're not sure the user wants + it kept) +- The user is actively reviewing the diff and may ask for changes +- The change touches the wire format or a trait shape that backends or + consumers implement (one-way doors — see "Wire formats are stable" and + "Backend trait shapes are one-way doors" below; once consumers exist, + those signatures are wire-stable contracts) +- You'd be force-pushing, amending a published commit, creating an empty + commit, or skipping hooks + +Never commit secrets, keys, or credentials. If a commit fails or hooks +reject it, fix the issue and create a new commit — do not amend the +failed one. + +Git identity is preconfigured (`glm-5.3-flash `). Do +not change `git config`, skip hooks, or use `git commit -i`. + +## Project Conventions (Rust / SOCKS5 protocol crate) + +This is the SOCKS5 protocol crate — a wrapper around `fast-socks5` that +offers the SSH `-D` (dynamic / SOCKS5 proxy) capability alongside +alktunnels' `-R` and `-L`, composed with alkcall channels ("ALPN as a +service"). It sits in the alk* family: alkcall (call + channels — the +substrate), alktty (terminal sessions), alktunnels (arbitrary `-L`/`-R`/ +tunnels). The distinguishing posture of this crate: **the SOCKS5 server +never binds a port unless explicitly configured to** — the protocol is +served inside alkcall channels data channels (a produced, ACL-scoped +resource), not on a kernel socket. A local bind is one optional +assembly-layer shape, not the protocol's home. The conventions below +apply to all work in `src/` and `tests/`. They mirror +`.opencode/agents/implementation-specialist.md` §Project Conventions and +are repeated here so they apply to every session, not just spawned +implementation agents. + +1. **No comments in code** unless the user explicitly asks. This is a + project-wide convention. Doc comments (`///`, `//!`) are fine and + expected on public API. Inline `//` comments only when the user asks + or when a non-obvious safety/correctness constraint would otherwise + be missed (e.g., "a two-pump tunnel must shut down the opposite sink + on pump completion — `try_join!` alone deadlocks; see alkcall + ADR-050's `pump_bidi` / alknet ADR-078"). + +2. **Error handling** — `thiserror` for library error types; map SOCKS5 + reply codes (`fast_socks5::ReplyError`) faithfully per RFC 1928. No + panics in library code. No `unwrap()` or `expect()` outside tests. If + you reach for `unwrap`, the error path wasn't specified — stop and + decide what should actually happen. For poisoned `RwLock`/`Mutex`, + use `unwrap_or_else(|e| e.into_inner())` so a panic in one operation + does not cascade to other operations. + +3. **`tokio` is the async runtime** — all I/O is async. `fast-socks5` is + tokio-native; the wrapper's protocol layer, pumps, and client session + types are all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) + for lifecycle correlation. Any non-wasm backend module (local TCP + listener for a "real" `socks5://` endpoint) lives behind a feature + flag like alktty/alktunnels' `local`. + +4. **WASM target is load-bearing (by default)** — the default crate + (protocol-only) should compile to `wasm32-unknown-unknown`, following + the alktty/alktunnels precedent. Use the wasm-clean tokio subset + (`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features + = ["full"]`** — and keep socket/platform I/O feature-gated. Note: + `fast-socks5` itself uses `tokio::net` and `socket2` unconditionally; + whether it compiles wasm-clean, needs a fork, or the wrapper + reimplements the SOCKS5 state machine over `tokio::io` generics is an + open question (see `docs/research/phase-0.md` OQ-SK-04) — do not + assume either way until it is resolved. + +5. **Wire format is stable** — the SOCKS5 protocol itself is RFC 1928 + (fixed); this crate's wire surface is the ALPN + the channel open-op + params that carry it. Those params are a one-way door once consumers + exist. Follow the alktunnels precedent: params are a self-contained + JSON object identifying the produced resource (alkcall ADR-039 — + `params` is ALPN-specific, interpreted by the open handler). Any + params-format ADR must be written before the first consumer exists; + after that, changes are additive-only. + +6. **Producer/consumer, not server/client** — both sides of a channels + connection can initiate. A producer exposes the SOCKS5 service + (registers openable channels via `ChannelCore::register_openable`); a + consumer opens SOCKS5 channels and speaks RFC 1928 inside them. Both + sides can be both simultaneously — connection direction (who opened + it) is independent of service direction (who dials targets, who + serves). Avoid "server" and "client" framing in docs and API names; + use "producer" and "consumer," or "accept side" / "connect side" for + the connection-establishment half specifically. (SOCKS5's own + client/server roles are RFC vocabulary and keep their RFC names + inside the protocol layer.) See alkcall ADR-022, ADR-037. + +7. **Substrate-agnostic by construction** — the protocol layer must not + know whether the far side of a SOCKS5 connection is a kernel TCP + socket, a channels `BiStream`, a unix socket, or an in-process pipe. + `fast-socks5`'s explicit server API is generic over + `T: AsyncRead + AsyncWrite + Unpin` (`Socks5ServerProtocol`, + `transfer(inbound, outbound)`); the wrapper must preserve that + genericity — the channels adapter feeds the SOCKS5 state machine a + `BiStream` (which is `AsyncRead + AsyncWrite`), the local backend + feeds it a `TcpStream`. Substrate-specific types are confined to + feature-gated backend modules, injected at the assembly layer — the + same inversion-point pattern as alktty's `TtyBackend` and + alktunnels' pump halves. + +8. **Two-pump shutdown-on-completion is a contract** — the SOCKS5 + CONNECT data plane is the canonical two-pump shape (one pump per + direction). Each pump MUST shut down the opposite sink when it + completes; `tokio::try_join!` alone deadlocks. The helper is pinned + upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 R-03) + — use it; do not hand-roll the two-pump shape. The channels layer + drops all per-channel senders on transport EOF — rely on that for + teardown, and emit EOF sentinels (zero-length chunks) on clean sink + shutdown. Await `pump_bidi` inline inside the `OpenHandler`'s task — + the returned `JoinHandle` must track the data-plane lifetime (R-02; + early return = teardown-at-birth). + +9. **No forced local binding** — the defining requirement of this crate. + The SOCKS5 server must run without binding any port: the RFC 1928 + conversation is carried inside a channels data channel, and target + dials happen on the producing side (or hop further through + alktunnels/alksocks). A local bind (a genuine `socks5://host:port` + endpoint) is an optional assembly-layer/feature-gated capability. + UDP ASSOCIATE is the hard case: RFC 1928 replies with a UDP relay + address and the client sends datagrams to it, which seems to demand a + bind — how the relay address is virtualized (or the associate shape + adapted) is a Phase 0 question (OQ-SK-03). Binding decisions belong + to the caller (assembly layer), never the protocol crate. + +10. **Backpressure and limits are inherited, not redefined** — bounded + per-channel buffers, the 256-channel per-connection cap, monotonic + channel IDs, and the zero-length-sentinel EOF convention are + alkcall channels invariants (see alkcall ADR-040/041/034). The + crate consumes them via `ChannelCore`/`ChannelClient`; do not build + a second demux/mux or re-derive limits. SOCKS5 CONNECT is a single + stream per open (no sub-demux inside the channel) — one channel per + SOCKS5 session, no protocol-level flow key. + +11. **Vendored core types come from alkcall** — `Connection`, + `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`, + `Identity`, `IdentityProvider`, `AccessControl`, + `OwnershipProvider`, `HandlerError`, `StreamError` come from + `alkcall::core`. Do not vendor copies into this crate. alkcall is + v0.7.x — breaking changes are expected at this major-zero stage; + this is an early consumer, so we find and fix issues upstream + rather than working around them. Pin `alkcall = "0.7.1"` and bump + deliberately. The establishment surface (alkcall ADR-049 + + amendment 2) is load-bearing for this crate: SOCKS5 session opens + use `register_openable_with_establisher` so a refused/auth-failed + session is a typed `channel:open_failed` call error, never a + phantom channel; the establisher returns the negotiated handle via + `Establishment::new(plan)` (typed-opaque `ChannelPlan` — payloads + must be `Send + Sync`). The 0.7.0 identity surface (ledger + CF-005/CF-006) is load-bearing for auth mapping: the per-call + opener identity arrives on the open-op hooks — map it to SOCKS5 + credentials/ACL there, not via any in-band SOCKS auth unless + clients need RFC-facing username/password. + +12. **Access control** — the producer's openable channels get + authorization for free via `ChannelCore::register_openable`, which + wires `AccessControl` into the operation spec (the registry runs + the ACL before the open handler). Scope-gate SOCKS5 opens (e.g. + `SOCKS5_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and + optionally consult `OwnershipProvider` for resource ownership. + SOCKS5 is arbitrary-egress by nature — treat the open gate and the + target policy as the security boundary; per-target allowlists (if + any) are a producer-side policy question for Phase 1 (OQ-SK-05). + See alknet ADR-024 (registry layering, alkcall ADR-019), alknet + ADR-050 (ownership, alkcall ADR-011), and alktunnels' resolved + posture (target selection lives in the SOCKS5 protocol at the + producing side; whatever ACL governs the socks5 resource governs + everything reachable through it). + +13. **Feature flags** — substrate backends are feature-gated if the + need arises. The base crate should compile lean (no socket/platform + deps unless the feature is on). Verify both `cargo test` (default) + and `cargo test --all-features` pass if features are added. + +14. **Naming** — Rust standard: `snake_case` for functions/variables/ + modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for + constants. + +15. **Module structure** — one module per file under `src/`, re-exported + from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The + expected shape (pending Phase 1 pinning): the SOCKS5 protocol + wrapper (server state machine + client session), the channels + adapter (producer half — open handler for this crate's ALPN), the + consumer half (a typed client that opens SOCKS5 channels and + speaks RFC 1928 through them), and feature-gated backend modules + (the optional local-bind listener). Backend modules are + feature-gated and never imported from the protocol/adapter/client + modules. + +16. **ALPN naming** — this crate owns a `alk/`-prefixed ALPN (provisional + `alk/socks5`; final naming per alkcall ADR-004/ADR-006 convention). + One ALPN per protocol. The ALPN string is wire-stable once + published — decide via ADR before the first consumer. + +17. **Upstream is ours — make asks early.** We own `fast-socks5` + (`/workspace/fast-socks5`, v1.0.0, MIT) and alkcall. If the wrapper + needs a change upstream (a typestate hook, a generic split, a new + event), file it and land it there rather than working around it + locally — the alktunnels precedent (the E-01/E-02 sweep, filed from + its Phase 0 and landed in alkcall 0.5.0 within a day). Keep + `/workspace/fast-socks5` as the reference checkout; if upstream + diverges from what we publish, note the fork point in the research + docs. + +## Verification Commands + +Run these before committing. All must pass. + +```bash +cargo test # full suite +cargo clippy --all-targets -- -D warnings +cargo fmt --check +cargo doc --no-deps # if docs changed +cargo test --all-features # if features are added +cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean +cargo clippy --target wasm32-unknown-unknown -- -D warnings +cargo publish --dry-run --allow-dirty # before a release +``` + +The wasm check is the structural guard for the "default crate is +wasm-clean" invariant (same as alktty/alktunnels) — run it whenever a +non-backend module changes. Until OQ-SK-04 (fast-socks5 wasm posture) is +resolved, the wasm check may fail on the dependency graph; that failure +is expected and is exactly what the OQ tracks. + +## Architecture Context + +- `docs/research/phase-0.md` — the Phase 0 (Exploration) document and + the current state of this repo: vision, prior art, open questions + (OQ-SK-01..NN), and POC candidates. Read it before non-trivial work. + The SDD process lives in `docs/sdd_process.md` (Phase 0 in progress; + `docs/architecture/` does not exist yet). +- The prior art for this crate: + - **alktunnels** — `/workspace/@alkdev/alktunnels` (v0.1, the closest + sibling): the producer/consumer tunnel crate on alkcall channels. + Its Phase 0 findings (`docs/research/phase-0-findings.md`) settled + the hub-owns-the-connection model, the two-pump shape, the + establishment story (alkcall ADR-049), and the `-D`-composes-at-the- + assembly-layer conclusion — this crate is that conclusion's + realization. Its Phase 1 ADRs (params shape, ALPN strategy, + access-control posture) are the template for this crate's Phase 1. + - **alktty** — `/workspace/@alkdev/alktty` (v0.4): the first + producer/consumer protocol crate on alkcall channels; the backend + inversion point, wasm-clean default crate, and feature-gated local + backend precedents. + - **alkcall** — `/workspace/@alkdev/alkcall` (v0.7.1, crates.io). The + substrate: call protocol + channels multiplexing. This crate + consumes `alkcall::core` types and the channels + `ChannelCore`/`ChannelClient`/`register_openable_with_establisher` + surface, same as alktty/alktunnels. + - **fast-socks5** — `/workspace/fast-socks5` (v1.0.0, MIT, we own + upstream). The SOCKS5 implementation to wrap: explicit typestate + server API (`Socks5ServerProtocol`), generic over + `T: AsyncRead + AsyncWrite + Unpin`; `run_tcp_proxy` / + `run_udp_proxy_custom` / `transfer` are the interception points; + client side has `Socks5Stream` (generic over the backing socket) + and `Socks5Datagram` (UDP associate). + - **alknet's SOCKS5 client** — + `/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` + (ADR-090): the client-side example need — `Socks5UdpSocket` + implements `quinn::AsyncUdpSocket` so QUIC rides SOCKS5 UDP + ASSOCIATE. Validated by the quinn-proxy POC + (`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/ + findings.md`). The open question for this crate's client story: the + same trait impl against **noq** (iroh's quinn fork, + `/workspace/noq` — `noq::AsyncUdpSocket`, `Endpoint:: + new_with_abstract_socket` exist but with a different surface shape; + see OQ-SK-06). +- Key upstream ADRs that inform this crate's design (alknet numbers; + alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/ + decisions/`): + - alknet ADR-093 / alkcall ADR-035 — channels pure channel + multiplexing (8-byte header, no `stream_type`); the SOCKS5 session + rides inside the channel's `BiStream` + - alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion; + the helper is pinned upstream as `alkcall::channels::pump_bidi` — + use it, do not hand-roll + - alkcall ADR-049 (amendment 2) — the establishment phase; the + establisher returns the negotiated handle via `Establishment::new` + (typed-opaque `ChannelPlan`, `Send + Sync` payload constraint) + - alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a + `BidiStreamSource`; every handler receives a `Connection` + - alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` / + `ChannelManager` (substrate-agnostic demux; `params` is + ALPN-specific — for this crate, the SOCKS5 service identity/policy) + - alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way + door); alkcall ADR-036 — channel 0 is pre-negotiated `alk/call`; + alkcall ADR-037 — channel lifecycle operations (`channel/open` on + channel 0) + - alkcall ADR-042 — hub relay; alktunnels' hub-proxy refinement — + the hub terminates and re-produces per-hop ACL (a SOCKS5 resource + is the same shape, "further downstream" — alktunnels + phase-0-findings §Prior art) + - alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving + identity seam: caller identity precedence (token > + `ServingConfig.identity` > transport), per-call opener identity on + the open-op hooks +- The suite decomposition context: alknet was the massive POC ("vpn-like + without being a vpn" + transport agnosticism) being rewritten; this + crate extracts and improves its SOCKS5 story (client side: ADR-090's + proxy support; server side: the `-D` capability alktunnels deferred + to composition). `alknet-client/src/socks5.rs` and the quinn-proxy + POC findings are the direct ancestors of this crate's client work. +- If a TODO references a design direction that a later ADR has decided + against, the TODO is stale — remove it and align with the ADR. Do not + implement the rejected design. \ No newline at end of file diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md new file mode 100644 index 0000000..6f7d0d2 --- /dev/null +++ b/docs/research/phase-0.md @@ -0,0 +1,551 @@ +--- +status: draft +last_updated: 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. + +Guiding principles, inherited from the alk* family: + +1. **"ALPN as a service," not "a server."** The SOCKS5 service is a + produced, ACL-scoped resource on a channels connection — the same + shape as an alktty terminal or an alktunnels TCP tunnel. A genuine + `socks5://host:port` kernel-socket listener is one optional + assembly-layer shape (a feature-gated local backend), never the + protocol's home. This inverts the usual SOCKS5 deployment posture and + is the crate's defining requirement. +2. **The `-D` conclusion, realized.** alktunnels' Phase 0 settled that + `-D` composes at the assembly layer: "`-D` is just tunnel a socks5 + connection"; target selection lives in the SOCKS5 protocol at the + producing side, governed by the same op-level ACL as the resource. + This crate is that conclusion's realization as a first-class protocol + crate, not an assembly-layer afterthought. +3. **Wrap `fast-socks5`, preserve its genericity.** `fast-socks5`'s + explicit typestate server API (`Socks5ServerProtocol`) + is generic over `T: AsyncRead + AsyncWrite + Unpin`, and its + interception points (`run_tcp_proxy`, `run_udp_proxy_custom`, + `transfer`) accept any such `T`. The channels adapter feeds the state + machine a `BiStream`; the local backend feeds it a `TcpStream`. The + wrapper must not leak either substrate into the protocol layer + (alktty `TtyBackend` / alktunnels pump-halves inversion-point + precedent). +4. **Producer/consumer vocabulary.** Both sides of a channels connection + can initiate; a producer registers openable SOCKS5 channels + (`ChannelCore::register_openable`), a consumer opens them and speaks + RFC 1928 inside. RFC 1928's own client/server roles keep their RFC + names *inside* the protocol layer, but crate-level docs and API use + producer/consumer. Avoid "SOCKS5 server" as a crate-level noun. +5. **Extract-and-improve from alknet.** alknet's SOCKS5 story is the + direct ancestor: the client side (`alknet-client/src/socks5.rs`, + ADR-090 — `Socks5UdpSocket` implements `quinn::AsyncUdpSocket` so + QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the + server side (the `-D` capability alktunnels deferred). This crate + rehomes both halves behind one protocol crate and improves them. + +## What is already settled + +The foundation is POC-validated and ADR-pinned upstream; this crate does +not start from zero. It inherits: + +- **The channels data path** — demux→Connection→handler→mux, validated + by the alknet-channels POC and production alktty/alktunnels. The SOCKS5 + payload is raw bytes inside a channels data channel's `BiStream`; + channels strips its 8-byte header transparently (alknet ADR-093 / + alkcall ADR-035). SOCKS5 CONNECT is one `BiStream` per session — the + RFC conversation *is* the stream; no sub-demux, no framing, no flow + key (one channel per SOCKS5 session). +- **The two-pump data plane** — the CONNECT proxy loop is the canonical + two-pump shape; `alkcall::channels::pump_bidi` (ADR-050) is pinned + upstream and alktunnels-validated. Use it; do not hand-roll. +- **The establishment story** — session opens use + `register_openable_with_establisher` (alkcall ADR-049 + amendment 2): + the establisher runs as an awaited, bounded establishment phase; a + refused session is a typed `channel:open_failed` call error + (`reason ∈ dial_failed / unknown_resource / resource_shortage / + handler_error / timeout`), never a phantom channel. The negotiate/ + auth half of the RFC conversation still runs in-stream over the + `BiStream` — establishment covers only the producer's accept policy, + not the RFC handshake. +- **The producer/consumer model** — producer registers openable channels + (authorization for free via `AccessControl`); consumer opens them via + `ChannelClient` (alkcall ADR-037, ADR-043). Connection direction is + independent of service direction. +- **The relay/hub story** — a SOCKS5 resource traverses alkcall hub + relays like any produced resource; the hub's terminate-and-re-produce + proxy (alktunnels' refinement of alkcall ADR-042) applies per hop, and + a SOCKS5 resource is exactly the "further downstream" shape alktunnels + described. No socks5-specific relay work. +- **The identity seam** — alkcall 0.7.0's CF-005/CF-006: the per-call + opener identity arrives on the open-op hooks. Auth mapping (identity → + SOCKS5 credentials/ACL, if any) happens there, not via in-band SOCKS + auth, unless RFC-facing username/password is needed for vanilla SOCKS5 + clients (OQ-SK-02). +- **The backend inversion point pattern** — substrate-specific types + confined to feature-gated backend modules, injected at the assembly + layer (alktty `TtyBackend` / alktunnels no-trait precedent). +- **The wasm-clean default crate** — protocol-only code should compile + to `wasm32-unknown-unknown` (alktty/alktunnels precedent). Caveat: + `fast-socks5` itself is not yet known to be wasm-clean (OQ-SK-04). + +## Prior art + +### alktunnels Phase 0 — the closest sibling + +`/workspace/@alkdev/alktunnels/docs/research/phase-0-findings.md`. The +load-bearing conclusions this crate inherits: + +- **Hub-owns-the-connection model** — role follows the resource; + whoever can reach the target is the producer, whoever wants the bytes + is the consumer. The "exposed port" is a virtual, ACL-scoped resource, + not a bind. For alksocks: the producer is the side that dials targets + (the egress side); the consumer is the side that wants proxied + egress. "A SOCKS5 service is the same shape [as a produced tunnel + resource], further downstream" is alktunnels' own wording — this + crate exists to make it literal. +- **`-D` composes at the assembly layer** — the original half-answer, + now with the mechanism named: a SOCKS5 server at some assembly layer + is just a consumer opening channels with per-connection dynamic + targets. The refinement this crate adds: the SOCKS5 *service* is + itself a produced resource (producer half), so a vanilla SOCKS5 client + (curl, a browser, ssh -D's own client half) can attach through the + optional local backend, and channels-native consumers get the same + service in-band. Both halves wrap the same protocol layer. +- **Target policy = resource policy.** alktunnels OQ-TN-08 dissolved + dynamic-target policy: whatever ACL governs the socks5 resource + governs everything reachable through it, plus whatever policy the + SOCKS5 implementation itself applies downstream. No target allowlists + in the base crate unless Phase 1 wants them (OQ-SK-05). +- **The codec conclusion does NOT transfer.** alktunnels needed + length-framing only for datagram substrates; a SOCKS5 CONNECT session + is a byte stream end to end (RFC 1928 defines its own framing on the + wire) — the channel payload is pure pass-through, 0 B overhead over + the channels 8-byte header. UDP ASSOCIATE's datagram stage is the + exception (OQ-SK-03). + +### fast-socks5 — the implementation to wrap + +`/workspace/fast-socks5` (v1.0.0, MIT, we own upstream). Read the source +before designing against it. Key surface points, verified: + +- **Explicit typestate server API** (`src/server.rs`): + `Socks5ServerProtocol`, + generic over `T: AsyncRead + AsyncWrite + Unpin`. Flow: + `start(inner)` → `negotiate_auth(&methods)` → + `finish_auth()` / `accept_no_auth` / `accept_password_auth` → + `read_command()` → (`reply_success`, `reply_error`). The legacy + `Socks5Server`/`Socks5Socket`/`Incoming` API (binds a `TcpListener`) + is deprecated — the wrapper uses the explicit API only. +- **Auth surface** — the `AuthMethod` trait (metadata: `method_id`, + `new`) + `AuthMethodSuccessState` (carry the socket back out); + `StandardAuthentication` enum for NoAuth + Password with static + dispatch; custom methods implement the trait. Username/password check + is a closure (`accept_password_auth`). The auth *decision* is where + the alkcall identity seam plugs in (OQ-SK-02). +- **Command handling is swappable** — the interception points: + - `run_tcp_proxy(proto, addr, timeout, nodelay)` — dials, replies, + then `transfer(inbound, outbound)` (a `copy_bidirectional` wrapper, + itself generic). This is where a channels-native dial replaces the + `tokio::net` dial: the wrapper's producer half intercepts here, + dials via alktunnels/alkcall primitives (or its own dial policy), + and returns the `T` back. + - `run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)` + — the customizable UDP ASSOCIATE handler: the wrapper supplies a + custom `transfer` closure that owns the relay half (OQ-SK-03). + - `transfer(inbound, outbound)` — plain two-pump copy; a channels + `BiStream` is a legal `T` on either side. +- **UDP support** — `new_udp_header(target)` / `parse_udp_request(buf)` + are public and pure (no socket I/O) — the SOCKS5 UDP datagram header + codec is reusable without the `Socket2`-based relay machinery. The + default `run_udp_proxy` binds two kernel sockets (`Socket2`, random + ports) — the *binds are in the default handler, not in the protocol*, + which is exactly the seam the no-bind requirement needs. +- **Client side** — `Socks5Stream` (generic over the backing socket; + `use_stream` upgrades any `AsyncRead + AsyncWrite + Unpin` already + connected) and `Socks5Datagram` (UDP associate; also accepts a + caller-supplied socket via `use_socket`). `Socks5Stream` impls + `AsyncRead + AsyncWrite` itself, so a consumer session over a + channels `BiStream` is the intended use, not a hack. Note: + `Socks5Stream::connect` convenience constructors dial + `tokio::net::TcpStream` and are non-wasm; `use_stream` is the + substrate-free path. +- **Error surface** — `ReplyError` (the RFC reply codes, `as_u8`/ + `from_u8`) and `SocksError`; `SocksServerError` on the explicit API. + Map these faithfully; never collapse a refusal into a generic error. +- **`router.rs` example** — the "conditional interception" template: a + server that inspects the command/target, then decides whether to + proxy, refuse, or handle in-process. Structurally the shape of the + channels open handler (inspect params → dial or refuse) and of the + alknet ADR-090 client (hand the established stream to quinn). + +### alknet's SOCKS5 client + the quinn-proxy POC — the client-side ancestor + +`/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` (ADR-090) +implements `Socks5UdpSocket: quinn::AsyncUdpSocket` — a SOCKS5 UDP +ASSOCIATE tunnel wrapped as the socket QUIC polls. The quinn-proxy POC +(`/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/findings.md`) +validated the approach end-to-end (5/5 clean runs) against quinn 0.11: +one public trait (`AsyncUdpSocket`), one public constructor +(`Endpoint::new_with_abstract_socket`), no fork. Known limitations +there, likely inherited: ECN is lost through the SOCKS5 header; the +proxy must support UDP ASSOCIATE; `may_fragment() == true` disables +path MTU discovery. + +This crate's client half rehomes that work (cleaned up, `ClientDialError` +→ thiserror types, and the datagram codec sourced from fast-socks5 +rather than hand-rolled inline). The open question is noq (OQ-SK-06). + +### noq — the quinn fork the client story must also fit + +`/workspace/noq` (v1.2.0; iroh's fork of quinn — iroh depends on +`noq = "1.2.0"`). Surface verified: + +- `noq::AsyncUdpSocket` exists (`noq/src/runtime/mod.rs:44`) but with a + **different shape** than quinn 0.11: `create_io_poller` + + `try_send` are replaced by `create_sender() -> Pin>` + with `poll_send(transmit, cx)` — a sender-object split (any number of + `UdpSender`s per socket, each holding its own waker). +- `RecvMeta` gained fields (`interface_index`, `timestamp`) but stays + default-constructible; `Transmit` is unchanged in the fields the + SOCKS5 wrapper touches (`destination`, `contents`, `ecn`, `src_ip`, + `segment_size`). +- `Endpoint::new_with_abstract_socket` exists (`noq/src/endpoint.rs:162`) + with the same doc-comment intent, but takes + `Box` rather than `Arc` — the + poller-removal reshuffle also changed the ownership shape. +- The alknet `Socks5UdpSocket` does **not** drop in unchanged: the impl + must be rewritten against `create_sender`/`poll_send`, and the + `Arc`→`Box` change ripples into construction. Whether one crate can + serve both quinn and noq behind feature flags (shared core, two thin + trait-impl shells) or whether the shapes have diverged enough to + justify two impls is the research question (OQ-SK-06). iroh's own + adoption makes noq support the practically-important target. + +### Anti-prior-art (what NOT to carry over) + +- **The alknet client's hand-rolled SOCKS5 codec** — + `socks5.rs` inlines the greeting/auth/CONNECT/ASSOCIATE byte logic + twice (handshake for UDP, again for CONNECT). fast-socks5's client + types and `new_udp_header`/`parse_udp_request` supersede it; the + wrapper should not vendor a second codec. +- **`Socks5Server` (the legacy bind-based API)** — deprecated upstream; + never the default path here. +- **In-band SOCKS auth as the primary auth story** — alkcall's identity + seam (CF-005/CF-006) authorizes at the open-op layer. In-band RFC + username/password remains available only for vanilla-client + compatibility (OQ-SK-02). + +## Open Questions + +These are the design questions Phase 0 must resolve (or explicitly +defer) before the architecture spec. Numbered OQ-SK-01.. so they can be +referenced, tracked, and promoted into `docs/architecture/ +open-questions.md` in Phase 1. Half-answers and hunches are marked as +such — the point of this document is to hold them without forcing +premature decisions. + +### OQ-SK-01: Producer dial policy — what dials the target? + +When the producer's SOCKS5 state machine reads a CONNECT command, the +target dial must happen *somewhere*. Options: + +- **Option A: dial via alktunnels** — the producer composes with a + local alktunnels consumer: the SOCKS5 handler opens an `alk/tunnel` + channel naming the target, and the two-pump data plane is + tunnel-channel ↔ SOCKS5-`BiStream`. Maximum composition ("further + downstream" made literal), but adds a runtime dependency and a hop. +- **Option B: dial directly** — the producer dials the target itself + (`TcpStream` behind the `local` feature, or an injected dial + callback). No alktunnels dependency; the dial policy (allowlists, + routing through another alksocks hop) is the caller's callback. +- **Option C: injected dialer trait** — a `Dialer`-shaped trait + (`async fn dial(target) -> impl AsyncRead + AsyncWrite`) with + alktunnels and local-TCP implementations behind features. Middle + ground; the trait is a backend-inversion-point decision (compare + alktunnels OQ-TN-05's resolution: *no trait*, a function producing + boxed halves sufficed). + +Considerations: substrate-agnostic-by-construction (AGENTS.md +convention 7) favors injection over hardwiring; the "hub terminates and +re-produces" story means a hub-hop SOCKS5 resource's dialer is just +"open a channel further downstream," which is Option A's shape — so +whatever is decided must not *prevent* A when composing. Hunch: a dial +callback/trait at the protocol layer, with alktunnels and local-TCP +providers feature-gated — but alktunnels' no-trait precedent warns +against a trait unless two real implementations converge; decide with +the Phase 1 spec. + +### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth + +The producer's open-op hooks receive the per-call opener identity +(alkcall CF-005/CF-006). The SOCKS5 RFC conversation also carries its +own optional username/password auth (RFC 1929). Questions: + +- Is the identity seam the *only* auth story (the open op is + authorized; the in-band handshake is skipped via + `skip_auth_this_is_not_rfc_compliant` or a no-op NoAuth), with + RFC-facing username/password available only for the optional local + backend (where vanilla SOCKS5 clients connect)? +- If both exist, how do they compose — does in-band auth *replace* the + channel identity for target-policy purposes, or is it a second gate? +- Does the wrapper need a fast-socks5 `AuthMethod` implementation that + consults the alkcall `AuthContext`/`Identity` (filed upstream if the + hook shape doesn't fit — AGENTS.md convention 17)? + +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 + `BiStream` carries length-prefixed datagrams (the alktunnels UDP + codec shape, `[len: u16 BE]` per datagram; 65507 < 65535 so u16 + suffices). The reply to the client rewrites `BND.ADDR`/`BND.PORT` + to a sentinel that means "same channel" — but vanilla SOCKS5 + clients will literally `sendto()` 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 + `BiStream` semantics clean but adds an open-op round trip and + needs correlation (params carry the association id?). + - **Local-backend bridge only** — UDP ASSOCIATE is offered only + through the optional local backend (which binds a real UDP relay + socket as the assembly layer's explicit choice), never + channels-natively. Simplest; weakens the "ALPN as a service" + story for UDP. +- **What does `BND.ADDR`/`BND.PORT` reply contain on the channels + path?** RFC says the relay address; a channels path has none. + fast-socks5's `run_udp_proxy_custom` lets 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 `Socks5UdpSocket` model, 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. + +Hunch: 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`/`socket2` behind a default-on + `net` feature (like alktty's `local`), leaving the codecs, typestate + machinery, and client `use_stream` path 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::io` generics (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). + +### 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, alktty + `TTY_OPEN_SCOPE` shape) — presumably yes, but the exact scope-string + convention should follow alkcall's registry conventions. +- Domain-form targets (RFC 1928 ATYP 0x03): resolve producer-side + (fast-socks5's `dns_resolve` config), refuse, or pass through to the + dialer unresolved? (DNS-on-the-producer is the SSH `-D` semantic; + wasm producers may not have a resolver at all — another OQ-SK-04 + interaction.) + +### OQ-SK-06: noq client support (and the quinn/noq shape split) + +The client half's UDP story is "implement the QUIC runtime's abstract +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>` +replaces `create_io_poller` + `try_send`, and the constructor takes +`Box` instead of `Arc`. + +Questions: + +- Does this crate ship `Socks5UdpSocket` against noq (behind a feature + like `noq`), quinn (behind `quinn`), or both? Both implies a shared + substrate-free core (associate handshake, datagram codec, flow table) + with two thin trait shells — feasible only if the shared core is + genuinely trait-agnostic. iroh's adoption makes noq the + practically-important target; alknet's precedent is quinn. +- noq is not on crates.io at this version (iroh consumes it from the + n0 workspace/git) — how does this crate depend on it (git dep? wait + for publication? feature-gate so it is optional)? This may block + `cargo publish --dry-run` for the client features; a publish-lean + default (quinn optional, noq git-optional) may be needed. +- Do the ECN/MTU limitations carry over (they should — the SOCKS5 UDP + header has no ECN field), and does noq's `may_fragment` default + (`true`) behave the same as quinn's? + +This is a research question first (read noq's endpoint/driver loops; +write the trait-shape comparison), then possibly a POC (OQ-SK-07 #3). + +### OQ-SK-07: POC scope for what remains unvalidated + +Candidates, in rough priority order (per the SDD process's "validate +promising approaches"): + +1. **Channels-native SOCKS5 CONNECT POC** — the producer half over a + real alkcall channels connection: consumer opens a SOCKS5 channel, + speaks RFC 1928 CONNECT (via fast-socks5's client types), producer's + open handler runs the typestate machine over the `BiStream`, dials a + local echo target, two-pump proxy loop completes. Validates: the + `register_openable_with_establisher` fit, `pump_bidi` as the data + plane, params shape (trivial — probably no params), and the + `BiStream`-as-`T` genericity claim. This is the crate's core value + proposition and the cheapest to validate (alktunnels' forward POC is + the template). +2. **UDP ASSOCIATE POC** — the OQ-SK-03 chosen shape, end to end: a + wrapper-aware client associates, sends length-prefixed datagrams + down the channel, producer relays to a real UDP endpoint. Validates: + the sentinel-reply semantics, the datagram codec (fast-socks5's + `new_udp_header`/`parse_udp_request` over the length framing), the + flow table, and empty-datagram handling (alktunnels F-2 transfers). +3. **noq `AsyncUdpSocket` impl POC** — the client-side story against + noq 1.2: associate through a fast-socks5 server, wrap as + `noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06 + (the `create_sender`/`Box` shape changes) before the client spec is + written. (The quinn variant is already proven by the quinn-proxy + POC — re-validating it here is optional.) +4. **fast-socks5 wasm check** — minimal crate, `cargo check --target + wasm32-unknown-unknown`, confirm/inflect OQ-SK-04. Cheap; can fold + into #1's worktree. + +POC placement conventions (inherited from alktunnels): a POC that needs +code from this repo runs in a worktree/branch (`.worktrees/research/ +/` 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 `-D` + composition conclusion, hub-owns-the-connection, the UDP codec + decision + F-2), `docs/architecture/` (params/ALPN/ACL ADR template), + POC summaries (POC placement and findings conventions). +- alkcall — `docs/architecture/decisions/` ADR-037/039/049/050 (channel + ops, params-is-ALPN-specific, establishment, pump_bidi), ledger + CF-005/CF-006 (identity seam), `src/channels/operations.rs` + (`ChannelCore`, `OpenHandler`, `Establishment`, `ChannelPlan`), + `src/channels/pump.rs`. +- alknet — `crates/alknet-client/src/socks5.rs` (ADR-090, the client + ancestor), `docs/research/quinn-quic-proxy/findings.md` (the POC + findings: trait + constructor surface, ECN/MTU limitations, + BotBrowser production precedent). +- noq — `/workspace/noq`: `noq/src/runtime/mod.rs` (`AsyncUdpSocket`, + `UdpSender`), `noq/src/endpoint.rs` (`new_with_abstract_socket`), + `noq-udp/src/lib.rs` (`RecvMeta`, `Transmit`), `Cargo.toml` (workspace + versioning / publication posture). +- alktty — backend inversion point (`TtyBackend`), `TTY_OPEN_SCOPE` + scope-gating shape, feature-gated `local` backend, wasm-clean + default-crate verification commands. +- tun2proxy — `/workspace/tun2proxy` `src/udpgw.rs` and its SOCKS5 + files (`socks.rs`, `proxy_handler.rs`): UDP-over-stream framing and + flow-table prior art (analyzed in alktunnels phase-0-findings; the + SOCKS5-specific parts feed OQ-SK-03). +- iroh — `/workspace/iroh` `iroh/Cargo.toml` (the noq dependency + posture: `noq = "1.2.0"` from the n0 workspace) — context for + OQ-SK-06's dependency question. + +## Convergence checklist (what Phase 0 must produce) + +- [ ] Vision + guiding principles captured (this doc, §Vision) +- [ ] 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; resolve via + research + POC #2, ADR before the first consumer +- [ ] OQ-SK-04 (fast-socks5 wasm) — verify empirically (POC #4); + likely an upstream feature-gating ask; file early per AGENTS.md + convention 17 +- [ ] 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 \ No newline at end of file diff --git a/docs/sdd_process.md b/docs/sdd_process.md index 0ee8a31..fef1e72 100644 --- a/docs/sdd_process.md +++ b/docs/sdd_process.md @@ -2,7 +2,7 @@ ## Overview -This document defines the SDD process for the @alkdev/alkcall package. It +This document defines the SDD process for the @alkdev/alksocks package. It leverages: - **OpenCode CLI** as the agent execution environment