--- id: implementation/door-tcp name: "Front doors: the TCP door (raw-pipe + RFC 1929 cred-bearing) and the BIND door" status: completed depends_on: [implementation/local-providers] scope: moderate risk: medium impact: component level: implementation --- ## Description The consumer's TCP front door (feature `local`): the ssh `-D` local exposure, a real `socks5://127.0.0.1:port` endpoint that vanilla clients (curl, browsers, tun2proxy) point at. Two variants (ADR-005), plus the BIND door's reply#2 fidelity. The door is the vanilla↔ACL boundary; the RFC codec lives only here. Deliverables: 1. **`src/door.rs`** (feature `local`), the door surface per consumer.md §The Front Doors + overview.md §Module Map: - **The NoAuth door** (POC #1's raw-pipe shape): bind a real `TcpListener`; per accepted connection, pipe it raw onto a channel `BiStream` (`copy_bidirectional`, **zero protocol translation**). The producer runs the whole RFC conversation through the pipe; the door adds nothing. This is the loopback-only / wrapper-aware shape. - **The cred-bearing door** (RFC 1929): the door terminates the local RFC conversation itself: `accept_password_auth`-shaped (the forked surface: `Socks5ServerProtocol::start` → `negotiate_auth(&[PasswordAuthentication])` → `read_username_password` → check → `accept`/`reject`), then maps the accepted credential through the **static config table** (`user:pass →` channel identity/endpoint selection, ADR-005) and **re-originates the SOCKS conversation in-band** via the consumer sessions (`Socks5ChannelSession::connect` / `BindSession::bind`), piping the vanilla client's post-auth bytes onto the session's pipe. **Do NOT pipe raw after auth**: the vanilla client's command bytes were consumed by the door's own RFC 1929 half; the in-band path re-speaks them from the parsed target. The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client. - **The identity mechanism**: the credential→identity mapping yields an alkcall `Identity` (scopes included); the door opens the producer channel with it. The landed consumer sessions take `&ChannelClient`, so the door's per-credential identity rides the open-op call's `auth_token` payload (CF-005 precedence (1): payload `auth_token` → `identity_provider` resolution) or a per-credential `ChannelClient`; decide at implementation against the landed alkcall 0.8.0 surface (`CallConnection::call_with_payload` carries `auth_token`; `ServingConfig.identity_provider` resolves it producer-side) and record the choice in Notes. The door checks credentials itself (no forked `AuthMethod` impl needed, per ADR-005). - **The BIND door**: same listener shape; a vanilla BIND conversation rides `BindSession` (reply#1's advertised address passes through to the vanilla client unchanged, because the conversation is the client's own; the door adds nothing, consumer.md §The BIND door's reply#2 fidelity). The BIND door rides the same door variant split (NoAuth raw-pipe vs cred-bearing re-origination). - **The accept path owns the idle defense** (ADR-014): an accepted connection that sends no first byte within a configured window is closed door-side (an accepted socket is not a channel, so the cap regime does not bound it); plus per-credential/per-peer session caps and accept-queue bounds as constructor config. Keep the v1 shape simple: an idle-first-byte timeout + a max-concurrent-sessions cap are the two knobs. - **Door variant selection** is constructor config (ADR-005). - Builder shape per consumer.md §Wiring: `FrontDoor::builder() .credentials(cred_table).bind("127.0.0.1:1080").build()` → `serve(channel_client)` (the NoAuth door omits `.credentials`). `serve` runs the accept loop; per-connection failures are logged + isolated (the loop continues, matching the alktunnels per-accept isolation posture). 2. **Tests** (`tests/door-tcp.rs`): a vanilla RFC 1928 client (the differential-suite raw-client pattern) through the NoAuth door → CONNECT round-trip end to end (the POC #1 curl shape); the cred-bearing door: good creds → RFC 1929 success → CONNECT round-trip; bad creds → RFC 1929 rejection, no channel opened (assert via the identity witness / channel count); the BIND door: a vanilla BIND conversation through the door (the POC #5 door shape); the idle-first-byte timeout closes a silent connection; the session cap refuses beyond the bound; per-connection failure isolation (one dead client doesn't kill the door). ## Acceptance Criteria - [x] `cargo test` passes; `cargo test --all-features` passes - [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check` clean - [x] Default wasm checks stay green (door code is `local`-gated) - [x] The NoAuth door is a literal raw pipe (zero protocol translation), pinned by test - [x] The cred-bearing door terminates RFC 1929 locally and re-originates in-band (never pipes raw post-auth), pinned by test (a vanilla client's command bytes must not reach the producer as greeting bytes) - [x] The credential→identity mapping is the static config table (ADR-005); the password never crosses the channel as a SOCKS password; pinned by test in that the producer's establisher witness sees the mapped identity, never the password) - [x] The idle-first-byte timeout + session cap land as constructor config, pinned by tests - [x] Per-connection failure isolation, pinned by test ## References - docs/architecture/decisions/005-auth-split-by-door.md (the door auth posture), 014-idle-timeout-posture.md (the door's accept-path defense), 003 (the RFC codec at doors only) - docs/architecture/consumer.md §The Front Doors, §Wiring - docs/architecture/overview.md §Module Map (`door.rs`) - POC references: `/workspace/alksocks-connect-poc/tests/ connect_poc.rs` (`front_door_raw_pipe_vanilla_client`), `/workspace/ alksocks-bind-poc/tests/bind_poc.rs` (`bind_vanilla_front_door`) - Landed surfaces: `src/consumer.rs` (the session types), `src/rfc1928/server.rs` (`accept_password_auth`, the forked cred-bearing shape), alkcall CF-005 (`auth_token` precedence) ## Notes > Filled by the implementation agent (2026-09-25). - **The identity mechanism, decided (the task's open choice)**: the per-credential identity rides the open-op call's `auth_token` payload (CF-005 precedence (1)), never a per-credential `ChannelClient`. Mechanically: the door wraps the client's channel-0 `CallConnection` once (`TokenClient::wrap`, the raw `call_with_payload` surface, since the token is a payload-level field, ADR-017 §7) and each credential's open rides the payload `auth_token`; the producer's `ServingConfig.identity_provider` resolves it and the ACL gate reads the resolved scopes exactly as for any per-call opener identity. The token is the mapped identity's *id* (the resolution seam's vocabulary); the password never rides it). Rationale: one transport, one call connection, many identities, since a per-credential `ChannelClient` would need a full transport connection per credential, which the ssh `-D` single-socket posture cannot afford. - **New consumer surface (`src/consumer.rs`)**: `TokenClient` (the token-carrying open surface over the taken `CallConnection` + shared manager) and the `*_with_token` twins of the three session constructors (`Socks5ChannelSession::connect_with_token`, `BindSession::bind_with_token`, `AssociateSession::associate_with_token`) through a `open_with_token_and_negotiate` prologue (the mirror of `open_and_negotiate`: open-with-token → adopt → negotiate → teardown-at-birth on failure). `Socks5ChannelSession` gained the `reply_addr` field + accessor (the CONNECT reply's BND.ADDR, captured at `connect`; the door's vanilla-reply re-encoding rides it). The existing constructor surfaces are untouched. - **The door (`src/door.rs`, feature `local`)**: - `FrontDoor::builder().credentials(...).bind(...).build()` → `serve(client)`; `serve_on(listener, client)` is the pre-bound seam (tests/assembly wire their own listener; the bind decision stays the caller's). - **The NoAuth door**: accept → `open_channel` → `copy_bidirectional(accepted_socket, BiStream)`, the literal raw pipe; zero translation (POC #1's shape). - **The cred-bearing door**: the forked `accept_password_auth` shape locally (`negotiate_auth(&[ PasswordAuthentication])` → `read_username_password` → check → `accept`/`reject`), the checker closing over the static `CredentialTable` (`user:pass → Identity`, the ADR-005 static mapping; the check and the mapping are one lookup), then `read_command` for the re-origination's parsed target, then the in-band sessions with the mapped identity's id as the token. Replies are re-encoded to the vanilla client (the RFC codec at the door boundary only, ADR-003): the CONNECT reply from `reply_addr()`, the BIND door's reply#1 from `reply1() .bind_addr`, reply#2 from `await_inbound()`'s plane peer; the post-reply data planes ride `copy_bidirectional` against the taken in-band streams. **Never pipes raw post-auth**, since the vanilla client's greeting/command bytes were consumed by the door's RFC half; the in-band path re-speaks them from the parsed target (pinned by test: the cred-bearing CONNECT round-trip succeeding at all is the pin, since raw piping would misparse the command bytes as a greeting). - **The BIND door**: same listener shape, riding `BindSession::bind_with_token`; the quiet wait rides the session's `await_inbound`; reply#1's advertised address passes through unchanged (the door adds nothing). - UDP ASSOCIATE through the TCP door: refused RFC-faithfully at the door (reply code 0x07; the UDP door's task, not this one). - **The accept-path defense (ADR-014)**: the idle-first-byte window (a `peek`-based readability wait before any protocol work; an accepted connection silent past the window is closed door-side) and the max-sessions cap (a `Semaphore`; an accept beyond the bound is refused immediately, no channel opened) as constructor config. Defaults: 30s / 256. - **Failure isolation**: per-accept `tokio::spawn`; failures log + isolate; the accept loop continues (the alktunnels posture). - **Harness**: `wire_consumer_side_with_provider` added (the consumer-side topology with the producer's `ServingConfig.identity_provider` configurable (the CF-005 precedence (1) seam the token sessions ride); `wire_consumer_side_with` delegates unchanged. - **Test-provider note**: the test `DoorTokenProvider` resolves `door-`-prefixed tokens (the tests' credential-table identity ids) to open-scoped identities (bind-scoped when listed). The token→ identity resolution is producer policy, not wire shape. ## Summary > Filled on completion (2026-09-25). Implemented the consumer's TCP front door (feature `local`): the NoAuth door (the literal raw pipe: accept → channel `BiStream` via `copy_bidirectional`, zero protocol translation), the cred-bearing door (RFC 1929 terminates locally through the forked `accept_password_auth` shape; the static credential→identity table maps the accepted credential; the SOCKS conversation re-originates in-band via the token-carrying consumer sessions with the vanilla replies re-encoded at the door; the password never crosses the channel as a SOCKS password), and the BIND door (reply#1 fidelity: the advertised address passes through unchanged; the quiet wait + reply#2 + the pass-through data plane). The identity mechanism decided + recorded: the per-credential identity rides the open-op `auth_token` payload (CF-005 precedence (1)) via the new `TokenClient` (the client's call connection taken once per door, shared manager), resolved producer-side through `ServingConfig.identity_provider`. The ADR-014 accept-path defense landed as constructor config: the idle-first-byte window (peek-based, door-side close) + the max-sessions cap (semaphore-refused accepts), plus per-accept failure isolation (the loop continues). New consumer-session surface: `TokenClient` + the `*_with_token` constructor twins + `Socks5ChannelSession::reply_addr` (existing surfaces untouched). 10 integration tests in `tests/door-tcp.rs` pin: the NoAuth raw-pipe round-trip (the POC #1 curl shape), the cred-bearing good/bad-credential paths (the witness sees the mapped identity id, never the password; a rejection opens no channel), the never-pipes-raw-post-auth pin, the BIND door's vanilla conversation (reply#1/reply#2 fidelity + the data plane), the idle timeout, the session cap, failure isolation, and the regression guards (native sessions beside a NoAuth door; `bind_with_token` driven directly). Verification: `cargo test` (375 green across 10 targets) + `cargo test --all-features` (423 green), `cargo clippy --all-targets -- -D warnings` clean (default + `--all-features`), `cargo fmt --check` clean, both wasm checks (`cargo check` + `cargo clippy -D warnings` on `wasm32-unknown-unknown`) green, and the default dependency graph stays net-free (the door rides `local` alone).