adr-014: the idle-timeout posture — teardown-governed phases, no protocol-layer idle bound
- ADR-014 accepted (review 002 C-2): the pre-command handshake, the BIND quiet wait, and the association lifetime are all teardown-governed; the protocol layer never times out (option 1) - the BIND relationship recorded as same regime (ADR-007 decided one phase's ends by designing BIND; ADR-014 records the inherited resolution — one principle, not two) - the doors' accept-path obligations recorded pre-batch-2 (accept-stage idle bound, per-credential/per-peer caps, accept-queue bounds); revisitable additively (an optional producer-side idle seam would be config, not wire) - spec lines carry the decided posture: wire.md (association-lifetime bullet, no-in-band-control bullet, preamble, frontmatter); producer.md (the open handler, the local-backend section, decision table, frontmatter); overview.md ADR index - one upstream-fidelity correction: upstream's dial timeout maps to 0x06 (TTL expired), not 0x01 as the review sketch suggested - open-questions.md unchanged (C-2 was never tracked there) Verification: cargo test green (357 passed), clippy -D warnings clean, fmt clean, cargo doc clean, wasm check + clippy (wasm32) clean
This commit is contained in:
1 parent
ff3295422c
commit
986bd13ce7
5 files changed
+309
-28
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
[consumer.md](consumer.md) (the other half),
|
||||
[ADR-014](decisions/014-idle-timeout-posture.md) (the
|
||||
teardown-governed phase lifetimes)
|
||||
+26
-12
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user