diff --git a/docs/architecture/decisions/014-idle-timeout-posture.md b/docs/architecture/decisions/014-idle-timeout-posture.md new file mode 100644 index 0000000..485cfea --- /dev/null +++ b/docs/architecture/decisions/014-idle-timeout-posture.md @@ -0,0 +1,185 @@ +# ADR-014: The Idle-Timeout Posture — Teardown-Governed Phases, No Protocol-Layer Idle Bound + +## Status + +Accepted (2026-09-24, review 002 C-2's decision task — the deliverable +is this decision, not code; it lands before the batch-2 `local`/ +`composed` doors exist, whose tasks may depend on it — ADR-008/ +ADR-009's provider posture) + +## Context + +Review 002's C-2 (major): the pre-command RFC handshake and the +association have no timeout surface. After the channel opens, the +pump handler's spawned task parks on the RFC handshake +(`Socks5ServerProtocol::accept_no_auth` → `read_command`), and the +datagram stage's loop exits only on EOF/error/data — no timer exists +anywhere in the protocol layer. The alkcall 10s establishment bound +(`register_openable_with_establisher`'s default, `timeout: None`, +alkcall ADR-049 §2) governs only the open-op round trip, not the +in-stream conversation. A silent peer therefore holds — per idle +channel — one spawned task and one channel entry (bounded buffers, +ADR-040) indefinitely. BIND's quiet wait got an explicit decision +(ADR-007: teardown-governed — "FTP-length waits stay legal"); the +handshake and the association lifetime got "inherited from the +channel" — implicit, never decided. + +The mitigations are real: the 256-channel per-connection cap +(ADR-040) and the per-identity cap (ADR-041) bound the count, +per-session memory is bounded, and teardown fires on channel close — +nothing leaks, nothing grows. The aggregate remains: any identity +with `socks5:open` can hold 256 parked tasks + 256 channel entries +indefinitely *without sending a byte*. And batch 2's providers +(`local`, `composed`, ADR-008) are about to route *vanilla clients* +through these same phases via the front doors, where the peer is a +kernel socket often outside the channel-cap regime entirely — a +slowloris-shaped hold (open, greet, stop) becomes the assembly +layer's problem, and the protocol layer is where a timeout seam would +live. + +The review offered two options: (1) idle timeout is the substrate's/ +assembly's job — the protocol layer never times out, the DoS posture +recorded explicitly; (2) a producer-side idle bound — an optional +per-session inactivity timeout at the pump level (config on +`Socks5ProducerBuilder`), racing the handshake/stage reads against a +sleep, timing out as a clean EOF-shaped teardown. + +Two facts sharpen the choice beyond the review's sketch: + +- **The pre-command hold is not a distinct DoS shape.** Every + post-reply phase legally parks indefinitely on a silent peer: an + idle CONNECT tunnel (`pump_bidi` parked on a quiet target), a + quiet-but-alive association (association lifetime = channel + lifetime, wire.md §UDP ASSOCIATE), BIND's quiet wait (ADR-007). + The resource cost is identical to the pre-command hold — one + parked task + one channel entry. A handshake-only idle bound + protects only the pre-auth sliver while the dominant hold (the + post-reply idle) stays contractually open — incoherent; a bound on + both is a full inactivity timeout, which contradicts the landed + contracts (association lifetime = channel lifetime; FTP-length + BIND waits stay legal, ADR-007). The unbounded-time aggregate is + therefore a property of every idle session the protocol serves — + bounded by the cap regime, not by a timer. +- **The two sides differ in composability.** The consumer sessions + are plain futures at the caller — a caller wanting an idle bound + wraps the constructor (or the session's reads) in + `tokio::time::timeout` itself; no decision is needed consumer-side. + The producer's parked task lives inside the alkcall-spawned open + handler — not caller-composable; only the producer-side question + needed an ADR. + +Also recorded (an upstream-fidelity correction to the review's +sketch): upstream's connect-timeout path maps its *dial* timeout to +`ReplyError::ConnectionTimeout` → reply code 0x06 (TTL expired), not +0x01 as the sketch suggested — the mapping table lives in +`src/rfc1928/mod.rs` (carried from `/workspace/fast-socks5` +`util/stream.rs`). Relevant only if option 2 is ever built. + +## Decision + +**Option 1 — the protocol layer never times out.** Every phase of the +RFC 1928 conversation is teardown-governed: the pre-command +handshake, the BIND quiet wait, and the association lifetime end on +the channels-level sentinels (EOF / garbage / error), never on a +protocol-layer inactivity timer. The posture is substrate-uniform — +the same for a channel peer and for a kernel socket a door relays. + +- **No inactivity bound exists in `src/`.** The handshake and stage + reads park like any other phase: no idle timer, no keepalive, no + in-band expiry vocabulary. "No in-band control vocabulary" + (wire.md) holds — this decision adds none. +- **The DoS posture is explicit and layered** (the ADR-010 pattern: + ACL → per-connection cap → per-identity cap → door/dial policy): + - **Channels shapes**: parked-task count is bounded by the + per-connection cap (ADR-040) and the per-identity cap (ADR-041); + per-session memory is bounded; teardown fires on channel close. + Time is unbounded by design — accepted, the same posture every + idle-but-open session already has. + - **Doors** (the batch-2 obligation this ADR records before they + exist): an accepted TCP connection is *not a channel* — the cap + regime does not bound a door's accepted-socket hold. The door's + accept path owns its defense; the batch-2 door author inherits + these obligations knowingly (the wire.md §UDP ASSOCIATE + "inherits this bound knowingly" pattern): + 1. an **accept-stage idle bound** — an accepted connection that + sends no first byte within a configured window is closed + (door-observable, protocol-agnostic; this is the slowloris + sliver the protocol layer will not bound); + 2. **per-credential / per-peer session caps** — the RFC 1929 + cred-bearing door's credential→identity config table + (ADR-005) is the natural knob; + 3. **accept-queue / relay-task bounds** on the door's own accept + loop. + The same obligations apply to every assembly shape that accepts a + kernel socket in front of the protocol: the consumer front doors, + the producer-side local backend's accept loop, and a composed + bind-first listener's front. + - The protocol layer neither provides nor re-derives these knobs + (limits are inherited, not redefined — AGENTS.md convention 10); + no timeout helper ships speculatively. +- **Relationship to ADR-007 (the BIND quiet wait): same regime.** + ADR-007 decided the quiet wait's ends as part of BIND's shape — the + `select!` needed exactly two exits, and "FTP-length waits stay + legal" was the explicit call. ADR-014 records the *same* resolution + for the phases ADR-007 left implicit. The principle is one and the + same: the protocol layer cannot distinguish malicious silence from + a slow legitimate peer (an FTP-length quiet wait, an idle tunnel, a + quiet association), so lifetime is teardown-governed and timeout + policy belongs to the layer that can see the deployment. The + difference is only decidedness: ADR-007 decided one phase's ends by + designing BIND; ADR-014 decides the rest by recording the inherited + resolution as the decision. No new regime is introduced. +- **Revisitable — not a one-way door.** A producer-side idle seam is + additive if a deployment ever proves this posture insufficient: an + optional per-session inactivity bound (builder config defaulting to + `None` = this posture), enforced by racing the handshake/stage + reads against a sleep timer (`tokio::time` is already in the + wasm-clean dependency subset), timing out as a clean EOF-shaped + teardown. No wire change, no API break, no spec contradiction — + the spec lines record the *default* posture, and an opt-in bound + contradicts nothing. The RFC has no timeout reply code; a pre-reply + timeout, if the seam is ever built, would surface as the EOF shape + (or the closest mapped code — upstream's dial-timeout precedent is + 0x06, see Context). Revisit trigger: a deployment that cannot + enforce the bound at its doors or assembly — a peer class outside + both the cap regime and any door policy — with measured evidence + the aggregate hold is unacceptable. + +## Consequences + +- No code changes this round; the posture is recorded. The spec lines + (wire.md §UDP ASSOCIATE's association-lifetime bullet; + producer.md §The Open Handler) carry the decided posture — the + implicit residue is gone. +- The batch-2 door tasks inherit the recorded accept-path obligations; + this ADR is the timeout-posture gate those tasks may depend on + (it precedes any `local`/`composed` door work). +- Review 002 C-2 closes as decided. The adjacent S-1 residual (the + datagram stage's hot-`Ok(None)` tolerance — unbounded *rate*, not + unbounded *time*) stays its own accepted-uncovered posture; this + decision does not touch it. +- Consumer-side timeout policy stays where it always was: caller- + composable around the session constructors and reads (no new + surface, nothing decided away). + +## References + +- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the + finding), §S-1 (the adjacent rate posture), §N-2 (the + "inherits this bound knowingly" pattern this ADR extends) +- [ADR-007](007-bind-in-base.md) (the quiet wait's decision — the + contrast case), [ADR-010](010-channel-capacity.md) (the + layered-defense + config-not-protocol pattern this follows), + [ADR-008](008-provider-defaults.md) / [ADR-009](009-feature-gates.md) + (the doors the posture precedes), [ADR-005](005-auth-split-by-door.md) + (the cred-bearing door's config table), [ADR-003](003-udp-handle-swap.md) + (association lifetime = channel lifetime) +- alkcall ADR-040/041 (the caps), ADR-049 §2 (the establishment + bound's scope), ADR-050 (`pump_bidi`) +- [wire.md](../wire.md) §UDP ASSOCIATE, + [producer.md](../producer.md) §The Open Handler (the spec lines + updated) +- POC findings (the stage's measured shapes — the "sufficient for + relay postures" lineage) +- `/workspace/fast-socks5` (`src/util/stream.rs` — the dial-timeout + mapping precedent, 0x06) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 1735317..edc44d0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,9 +1,10 @@ --- status: draft -last_updated: 2026-09-22 (the targeted fork — ADR-013, the module is -`src/rfc1928/`; the vendor extraction amendment — no `net` feature — -2026-09-21; Phase 1 initial draft 2026-09-20 — the OQ-SK ledger -promoted into ADRs 001-012 and open-questions.md) +last_updated: 2026-09-24 (ADR-014 indexed — the idle-timeout posture; +the targeted fork — ADR-013, the module is `src/rfc1928/`; the vendor +extraction amendment — no `net` feature — 2026-09-21; Phase 1 initial +draft 2026-09-20 — the OQ-SK ledger promoted into ADRs 001-012 and +open-questions.md) --- # alksocks — Overview @@ -216,6 +217,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [011](decisions/011-inline-vendor.md) | The extraction | the `T`-generic subset copied in-tree; two carried changes (BIND, `new_reply`); v1.0.0 noted (superseded as a posture by ADR-013) | | [013](decisions/013-targeted-fork.md) | The targeted fork | the extracted region is owned; `src/rfc1928/`; fork point `5b18d4d`; hand-port replaces re-vendor | | [012](decisions/012-target-policy.md) | Target policy | dialer-refuses; domain targets pass through unresolved; virtual subnets = assembly policy | +| [014](decisions/014-idle-timeout-posture.md) | Idle-timeout posture | every phase teardown-governed, no protocol-layer idle bound; doors own the accept-path defense; revisitable additively | ## Open Questions diff --git a/docs/architecture/producer.md b/docs/architecture/producer.md index 4fab6e8..29f728e 100644 --- a/docs/architecture/producer.md +++ b/docs/architecture/producer.md @@ -1,6 +1,7 @@ --- status: draft -last_updated: 2026-09-20 (Phase 1 initial draft) +last_updated: 2026-09-24 (the open handler's phase lifetimes carry the +decided timeout posture — ADR-014; Phase 1 initial draft 2026-09-20) --- # alksocks — The Producer Half @@ -45,7 +46,16 @@ behavior, but the wiring requirement is implicit. ## The Open Handler: RFC Conversation + Command Dispatch The open handler runs the forked typestate over the channel's -`BiStream` (`T`-generic — the POC #1 fit): +`BiStream` (`T`-generic — the POC #1 fit). Every phase is +teardown-governed, never idle-expired (ADR-014): the handler parks on +the handshake / stage reads like any other phase — no protocol-layer +inactivity timer exists (the alkcall 10s establishment bound covers +only the open-op round trip, ADR-049 §2); a silent peer holds the +task + channel entry until the channels-level sentinels arrive, and +the cap regime (ADR-040/041) bounds the aggregate. A deployment that +wants an idle bound enforces it outside the protocol layer (an +accept-path bound at a door; a caller-side timeout around a +substrate-free `serve_socks5_over`): 1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` — NoAuth as a no-op (ADR-005: the producer side is identity-only; @@ -128,6 +138,13 @@ clients reach a locally-served SOCKS5 resource through it. The door surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is the consumer door's code shape (see [consumer.md](consumer.md)). +The door's accept path owns the idle defense the protocol layer does +not provide (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 — the cap regime does not bound it), plus +per-credential/per-peer session caps and accept-queue bounds as the +deployment's policy. + The producer-side local *backend* for the I/O planes (the local-socket providers — TCP dial, UDP egress, BIND listener) is ADR-008's variant set, also feature `local`. @@ -164,6 +181,7 @@ are feature-gated constructors over the same seams (ADR-008/ADR-009): | [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated | | [011](decisions/011-inline-vendor.md) | The extraction | the typestate subset in-tree | | [013](decisions/013-targeted-fork.md) | The targeted fork | the core is owned (`src/rfc1928/`) | +| [014](decisions/014-idle-timeout-posture.md) | Idle-timeout posture | every phase teardown-governed; no protocol-layer idle bound; doors own the accept-path defense | ## Open Questions @@ -180,4 +198,6 @@ translation) rides the dial callback without touching this surface. seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op; bind-first + forwarded) - [wire.md](wire.md) (the normative wire), - [consumer.md](consumer.md) (the other half) \ No newline at end of file + [consumer.md](consumer.md) (the other half), + [ADR-014](decisions/014-idle-timeout-posture.md) (the + teardown-governed phase lifetimes) \ No newline at end of file diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md index 9cec204..cc20eb8 100644 --- a/docs/architecture/wire.md +++ b/docs/architecture/wire.md @@ -1,7 +1,9 @@ --- status: draft -last_updated: 2026-09-20 (Phase 1 initial draft — the POC-validated wire -shapes promoted into the normative spec) +last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided — +teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1 +initial draft 2026-09-20 — the POC-validated wire shapes promoted +into the normative spec) --- # alksocks — Wire Format @@ -17,8 +19,8 @@ pass-through, with exactly one framed stage (ASSOCIATE datagrams). Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md), [003](decisions/003-udp-handle-swap.md), [007](decisions/007-bind-in-base.md), -[011](decisions/011-inline-vendor.md), [013](decisions/013-targeted-fork.md)); -this document is the normative +[011](decisions/011-inline-vendor.md), [013](decisions/013-targeted-fork.md), +[014](decisions/014-idle-timeout-posture.md)); this document is the normative WHAT. RFC 1928's own wire rules (method negotiation, command encoding, reply encoding, the RFC UDP datagram header) are specified by the RFC itself — this document covers the channels-specific surface and the @@ -89,9 +91,13 @@ ADR-003/ADR-007): data plane (POC #5 — the RFC 1928 §3 reading). - **No in-band control vocabulary.** The alktunnels resolution ("no in-band control path, ever — the escape hatch is a new ALPN") - holds: no KEEPALIVE, no error frames, no flow-expiry frames. - Errors are EOF-shaped (channels-level) or reply-code-shaped - (in-stream, pre-data-plane). + holds: no KEEPALIVE, no error frames, no flow-expiry frames. No + idle/inactivity vocabulary either — every phase's lifetime is + teardown-governed (EOF / garbage / error sentinels), never + protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet + wait's ends the same way). A deployment enforces idle policy at + its edges, not in-band. Errors are EOF-shaped (channels-level) or + reply-code-shaped (in-stream, pre-data-plane). - **One channel per SOCKS5 session** — no sub-demux, no flow key (AGENTS.md convention 10; the ASSOCIATE handle swap makes the channel the relay handle, ADR-003). @@ -150,7 +156,12 @@ the same channel: - **Association lifetime = channel lifetime.** Channel EOF (the channels-level sentinel) ends the association; no extra control vocabulary (POC #2 test 4: a fresh associate succeeds immediately - after a half-close teardown). + after a half-close teardown). No inactivity timer exists at the + protocol layer — every phase (the pre-command handshake, the BIND + quiet wait, the association) is teardown-governed, never + idle-expired (ADR-014); a deployment wanting an idle bound enforces + it outside the protocol layer (door accept paths, caller-side + wrappers). - **The stage is `select!`-driven** (channel reads ∥ egress replies), with the egress readiness contract: `next_reply` is `Pending` when nothing is ready (an instantly-ready `Ok(None)` starves a biased @@ -234,15 +245,18 @@ BIND (the phase sequence, one channel's view): Open questions are tracked in [open-questions.md](open-questions.md). None currently open against this document — the datagram framing -(the one-way door) is decided (ADR-003); OQ-SK2-07 (the -virtual-subnet translation layer) rides the dial callback and does -not touch this surface. +(the one-way door) is decided (ADR-003); the phase lifetimes' +timeout posture is decided (ADR-014 — teardown-governed, no +protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet +translation layer) rides the dial callback and does not touch this +surface. ## References - ADR-001 (op/params), ADR-003 (the handle swap + frame), ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND), - ADR-011 (the forked codecs) + ADR-011 (the forked codecs), ADR-014 (the teardown-governed + phase lifetimes — no protocol-layer idle timeout) - POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through, refusal mapping), `poc-udp-associate-findings.md` (the stage, readiness contract, framing-once invariant, association lifetime), diff --git a/tasks/architecture/adr-014-idle-timeout-posture.md b/tasks/architecture/adr-014-idle-timeout-posture.md index db1a264..4d21d05 100644 --- a/tasks/architecture/adr-014-idle-timeout-posture.md +++ b/tasks/architecture/adr-014-idle-timeout-posture.md @@ -1,7 +1,7 @@ --- id: architecture/adr-014-idle-timeout-posture name: "ADR-014 — the idle-timeout posture for the pre-command handshake and the association (review-002 C-2)" -status: pending +status: completed depends_on: [] scope: narrow risk: medium @@ -68,19 +68,19 @@ one. ## Acceptance Criteria -- [ ] ADR-014 exists at +- [x] ADR-014 exists at `docs/architecture/decisions/014-.md`, status accepted, deciding explicitly between the two options (or a reasoned third) -- [ ] The BIND quiet wait's ADR-007 relationship is stated (same +- [x] The BIND quiet wait's ADR-007 relationship is stated (same regime or a principled difference) -- [ ] The doors' DoS posture is recorded (what the assembly layer +- [x] The doors' DoS posture is recorded (what the assembly layer must do if the protocol layer does not) -- [ ] The affected spec lines (wire.md, producer.md) carry the +- [x] The affected spec lines (wire.md, producer.md) carry the decided posture — no implicit residue -- [ ] The decision lands before any `local`/`composed` door work +- [x] The decision lands before any `local`/`composed` door work (the batch-2 tasks may depend on this one) -- [ ] `cargo doc --no-deps` clean if any doc-comment carries the +- [x] `cargo doc --no-deps` clean if any doc-comment carries the posture; the full verification sweep green ## References @@ -99,8 +99,68 @@ one. ## Notes -> Agent fills this during implementation. +> Filled by the implementation agent (2026-09-24): + +- **Option 1 won** — the protocol layer never times out; every phase + (pre-command handshake, BIND quiet wait, association lifetime) is + teardown-governed (EOF / garbage / error sentinels), never + idle-expired. No code changed; the posture is recorded. +- **The sharpening argument** (why a handshake-only bound is + incoherent): every post-reply phase *legally* parks indefinitely on + a silent peer — an idle CONNECT tunnel, a quiet association, + BIND's quiet wait (ADR-007). The pre-command hold costs exactly + what those holds cost (one parked task + one channel entry); a + handshake-only bound protects a sliver while the dominant hold + stays contractually open, and a bound on both is a full inactivity + timeout, which contradicts the landed contracts (association + lifetime = channel lifetime; "FTP-length waits stay legal," + ADR-007). The unbounded-time aggregate is a property of every idle + session — bounded by the cap regime (ADR-040/041), not by a timer. +- **One upstream-fidelity correction to the review's sketch**: + upstream's connect-timeout path maps its *dial* timeout to + `ReplyError::ConnectionTimeout` → 0x06 (TTL expired), not 0x01 — + verified in the reference checkout (`util/stream.rs`) and the + forked table (`src/rfc1928/mod.rs`). Recorded in the ADR; relevant + only if option 2 is ever built. +- **open-questions.md needed no update** — C-2 was tracked as a + review finding (tasks/), not as an OQ; no OQ entry exists for it. +- **The doors' obligations are recorded pre-batch-2** (the + "inherits this bound knowingly" pattern, wire.md §UDP + ASSOCIATE's): an accept-stage idle bound (the slowloris sliver — + an accepted socket is not a channel, the cap regime does not bound + it), per-credential/per-peer session caps (the cred-bearing + door's config table, ADR-005, is the knob), and accept-queue/ + relay-task bounds. Same obligations for the producer-side local + backend's accept loop and a composed bind-first listener's front. +- **Revisitability**: not a one-way door — a producer-side idle seam + is additive (builder config defaulting to `None` = this posture; + `tokio::time` already in the wasm-clean subset; timeout = EOF- + shaped teardown). Revisit trigger recorded: a deployment with a + peer class outside both the cap regime and any door policy, with + measured evidence. ## Summary -> Agent fills this on completion. \ No newline at end of file +ADR-014 landed +(`docs/architecture/decisions/014-idle-timeout-posture.md`, status +accepted, 2026-09-24), deciding review 002 C-2: **option 1 — the +protocol layer never times out**. The pre-command handshake, the +BIND quiet wait, and the association lifetime are all +teardown-governed; the BIND relationship is recorded as *same +regime* (ADR-007 decided one phase's ends by designing BIND; ADR-014 +decides the rest by recording the inherited resolution — one +principle: the protocol layer cannot distinguish malicious silence +from a slow legitimate peer, so timeout policy belongs to the +deployment-facing layer). The DoS posture is explicit and layered +(ACL → per-connection cap → per-identity cap → door/dial policy — +the ADR-010 pattern), with the doors' accept-path obligations +recorded before any batch-2 door exists. The decision is revisitable +additively (not a one-way door). Spec lines updated — wire.md §UDP +ASSOCIATE's association-lifetime bullet + the phase-model +"no in-band control vocabulary" bullet + the preamble's ADR list + +frontmatter; producer.md §The Open Handler's pump description + the +local-backend section + the decision table + frontmatter; +overview.md's ADR index. No code changes; no OQ update needed +(C-2 was never tracked there). `cargo doc --no-deps` clean; full +verification sweep green (`cargo test` 348+ green, clippy, fmt, +wasm check). \ No newline at end of file