- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
210 lines
11 KiB
Markdown
210 lines
11 KiB
Markdown
---
|
|
status: 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
|
|
|
|
The producer exposes the SOCKS5 service as a produced, ACL-scoped
|
|
resource: it registers the `channels/socks5/sub` open op (ADR-001)
|
|
and serves RFC 1928 inside each opened channel. The layering is the
|
|
POC #1/#2/#5-validated shape: **establisher = session gate** (the
|
|
target is unknown at open time), **open handler = the RFC state
|
|
machine + command dispatch** (the forked typestate driving, ADR-002),
|
|
**I/O planes = the three seams** (dial/egress/accept, ADR-004).
|
|
|
|
## The Open Op and the Session-Gate Establisher
|
|
|
|
Registration uses `ChannelCore::register_openable_with_establisher`
|
|
(alkcall ADR-049) with:
|
|
|
|
- the op spec `channels/socks5/sub` on the `alk/socks5` ALPN,
|
|
`required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006);
|
|
- params schema `{type: object}`, `additionalProperties: false`
|
|
(trivial, ADR-001);
|
|
- the establisher: validates/records the opener identity (the CF-005/
|
|
CF-006 seam, ADR-005) and applies session-level policy. It runs
|
|
`Establishment::default()`-shaped (no plan payload): the dial
|
|
happens post-establishment, so there is nothing to thread
|
|
establisher→handler (POC #1's wire note).
|
|
|
|
Failure semantics: a scope-less identity is `FORBIDDEN` at the
|
|
registry ACL gate; establisher refusal is a typed
|
|
`channel:open_failed` (`handler_error`-class or the mapped reason),
|
|
never a phantom channel. Session-level refusal is channels-level;
|
|
target-level refusal is in-stream RFC (below).
|
|
|
|
**Harness wiring requirement** (POC #1, POC #2 re-witnessed): the
|
|
accept-side install hook must call `channel0_conn.set_identity
|
|
(resolved)` before building the `CallConnection`; the transport
|
|
identity does not flow to channel-0 dispatch automatically. A real
|
|
mTLS transport resolves the peer here. Without it, scope-gated opens
|
|
fail closed with `FORBIDDEN "authentication required"`: correct
|
|
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). 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;
|
|
authorization already happened at the open gate).
|
|
2. **Command read**: `read_command()` returns `(cmd, target)`, the
|
|
dispatch point:
|
|
- **CONNECT** → the command gate (`socks5:open` already checked
|
|
at the open gate, no extra check) → invoke `DialFn(target)`
|
|
(ADR-004). Success: `reply_success` then the pass-through data
|
|
plane. Refusal: `reply_error` with the mapped RFC code
|
|
(dial-failure → `ConnectionRefused` 0x05; scope-class refusal
|
|
→ `ConnectionNotAllowed` 0x02); no phantom data plane.
|
|
- **TCP BIND** → **the command gate**: require `socks5:bind`
|
|
from the per-call opener identity (ADR-006); an identity
|
|
without it gets `ConnectionNotAllowed` (0x02) while CONNECT
|
|
still serves the same identity (POC #5 test 4). Then the BIND
|
|
shape (ADR-007, below).
|
|
- **UDP ASSOCIATE** → `reply_success(sentinel)` (the
|
|
RFC-compatibility handle, ADR-003) then the datagram stage.
|
|
3. **Data plane** per command (wire.md §The Data Plane):
|
|
- CONNECT: `pump_bidi` inline inside the handler task (the
|
|
returned `JoinHandle` tracks the data-plane lifetime: R-02,
|
|
alkcall review 007,
|
|
`/workspace/@alkdev/alkcall/docs/reviews/
|
|
007-establishment-follow-ups-review.md`; early return =
|
|
teardown-at-birth).
|
|
- ASSOCIATE: the `select!`-driven datagram stage; the
|
|
`EgressFactory` is called once per association; the stage owns
|
|
the egress; stage exit drops it. Framing applied exactly once
|
|
by the wire layer; the readiness contract (`Pending` when
|
|
nothing is ready) is the seam's API invariant.
|
|
- BIND: reply#1 → the quiet wait (`select!`-single-shot; the
|
|
`BindAcceptFactory` future owns the listener) → reply#2 →
|
|
`pump_bidi` against the accepted halves.
|
|
|
|
## The Three Seams (ADR-004)
|
|
|
|
| Seam | Called | Returns | Refusal shape |
|
|
|------|--------|---------|---------------|
|
|
| `DialFn(target)` | per CONNECT, at command read | `TargetHandle {read, write}` (boxed halves, `+ Sync`) | RFC reply code |
|
|
| `EgressFactory()` → egress | once per ASSOCIATE; stage owns it | per-association egress (`next_reply` readiness contract) | reply#1 mapped code (pre-reply failure) |
|
|
| `BindAcceptFactory` | once per BIND session; quiet wait selects its future | accepted halves + peer | reply#1 (factory) / reply#2 (accept-stage) mapped codes |
|
|
|
|
Contracts (POC-pinned, load-bearing):
|
|
|
|
- **Ownership rides the handle structurally**: handle payloads own
|
|
their channel sessions/sockets; the compiler should enforce it (no
|
|
`_` bindings of owning values; the third teardown-at-birth
|
|
witness).
|
|
- **The egress speaks inner bytes** (`addr + data`); the wire layer
|
|
frames once.
|
|
- **The accept future owns its resources end-to-end**; the quiet
|
|
wait's `select!` drop IS the client-gone teardown.
|
|
- **No trait**: the re-evaluation threshold is three real
|
|
implementations as trait impls (the alktunnels ADR-004 posture).
|
|
|
|
## Scope Gating (ADR-006)
|
|
|
|
- `SOCKS5_OPEN_SCOPE = "socks5:open"`: the open op's
|
|
`required_scopes`; egress capability (CONNECT + ASSOCIATE).
|
|
- `SOCKS5_BIND_SCOPE = "socks5:bind"`: the BIND command gate,
|
|
checked at command dispatch from the per-call opener identity;
|
|
ingress capability, never implied by `socks5:open`.
|
|
- Target policy: the dialer/egress/accept callbacks refuse (RFC
|
|
reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever
|
|
ACL governs the socks5 resource governs everything reachable
|
|
through it, plus the callbacks' policy.
|
|
- Optional: consult `OwnershipProvider` for resource ownership, a
|
|
registration-time choice.
|
|
|
|
## Producer-Side Local Backend (feature `local`, optional)
|
|
|
|
The optional shape where the producer serves from a real listener:
|
|
a feature-gated backend binds `socks5://127.0.0.1:port` (or any
|
|
address) and hands each accepted TCP connection to the landed
|
|
substrate-free entry `Socks5Producer::serve_socks5_over` directly:
|
|
the RFC conversation runs on the accepted socket (the NoAuth
|
|
in-stream shape: greeting, command, reply, data plane, all on the
|
|
accepted socket), and the seams dial/egress/accept locally. The same
|
|
door shape in reverse: this is a *front door for the producer side*;
|
|
vanilla clients reach a locally-served SOCKS5 resource through it
|
|
(`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard
|
|
surface). A cred-bearing variant (RFC 1929) would wrap
|
|
`accept_password_auth` before `serve_socks5_over`, a documented
|
|
follow-up, not landed.
|
|
|
|
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 the
|
|
max-concurrent-sessions cap (an accept beyond the bound is refused
|
|
door-side, no session spawned); these are the two constructor knobs.
|
|
|
|
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`. The BIND command gate
|
|
on this path is the builder's `bind_policy` (there is no open-op
|
|
identity on an accepted socket; the accepted socket is the whole
|
|
caller, and the pump handler's identity-derived gate does not apply).
|
|
|
|
## Wiring (Assembly Layer)
|
|
|
|
```rust
|
|
let producer = Socks5Producer::builder()
|
|
.dial(dial_fn) // DialFn: required for CONNECT
|
|
.egress(egress_factory) // EgressFactory: required for ASSOCIATE
|
|
.accept(bind_accept_factory) // BindAcceptFactory: required for BIND
|
|
.build();
|
|
// register(&core, ®istry, auth, witness): the establisher rides
|
|
// the registration (the optional witness is the CF-006 test probe).
|
|
producer.register(&core, ®istry, auth, None)?;
|
|
```
|
|
|
|
Missing callbacks = those commands refuse RFC-faithfully
|
|
(`CommandNotSupported`-class), never a panic. Substrate providers
|
|
are feature-gated constructors over the same seams (ADR-008/ADR-009):
|
|
`local::tcp_dial()`, `local::udp_egress()`, `local::bind_accept()`,
|
|
`composed::direct_egress(...)`, `composed::bindfirst_accept(...)`.
|
|
|
|
## Design Decisions
|
|
|
|
| ADR | Decision | Summary |
|
|
|-----|----------|---------|
|
|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + op + params | `channels/socks5/sub`, trivial params, session-gate establisher |
|
|
| [002](decisions/002-protocol-base.md) | Protocol base | the forked typestate drives the conversation |
|
|
| [004](decisions/004-dial-callback-shape.md) | The seams | DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle |
|
|
| [005](decisions/005-auth-split-by-door.md) | Auth | producer = identity seam; in-band NoAuth no-op |
|
|
| [006](decisions/006-access-control.md) | ACL | `socks5:open` + `socks5:bind` command gate; dialer-refuses |
|
|
| [007](decisions/007-bind-in-base.md) | BIND | the phase model; the accept variants |
|
|
| [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
|
|
|
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
|
None currently blocking this document: the seams, gates, and BIND
|
|
shape are decided (ADR-004/006/007); OQ-SK2-07 (virtual-subnet
|
|
translation) rides the dial callback without touching this surface.
|
|
|
|
## References
|
|
|
|
- POC #1/#2/#5 findings (the three seams' empirical records)
|
|
- Upstream: alkcall ADR-047/049 (ops/establishment), ADR-050
|
|
(`pump_bidi`), ADR-042/051 (hub relay), CF-005/CF-006 (identity
|
|
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),
|
|
[ADR-014](decisions/014-idle-timeout-posture.md) (the
|
|
teardown-governed phase lifetimes) |