6 Commits
Author SHA1 Message Date
glm-5.3-flash 126ae9ccf6 release: v0.3.2 — the changelog link refs (the 0.3.2 compare line, the Unreleased base catch-up)
- CHANGELOG: the [0.3.2] compare link added (v0.3.1...v0.3.2); the
  [Unreleased] base re-pointed v0.3.0...HEAD -> v0.3.1...HEAD, the
  stale-base residue from the 0.3.1 entry landing without the
  0.3.2 refs.

Verification: cargo test (145 lib / 375 total), --all-features (171),
clippy -D warnings (default + wasm targets), fmt, doc, fuzz corpus
replay (3/3), publish dry-run (64 files) all green.
2026-11-03 19:43:12 +00:00
glm-5.3-flash 398e9340e3 docs(readme): drop the Version 0.1 section
Per-version release notes (0.1 through 0.3.2) duplicated the
changelog; that section was also stale-titled. The section is
removed; the changelog stays the release-history home.
2026-11-03 19:37:02 +00:00
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- 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
2026-11-03 19:31:53 +00:00
glm-5.3-flash 9ca8c52601 docs: the 0.3.2 pre-release bookkeeping (changelog + readme ride notes)
- CHANGELOG: the 0.3.2 entry (alktunnels 0.4.1 -> 0.5.0, the
  correlation-gate release; patch, additive-only, no path change).
- README: the 0.3.2 version-history paragraph, same pattern.

Release machinery (tag + publish) deliberately deferred until the
doc-cleanup session lands.
2026-11-03 18:08:43 +00:00
glm-5.3-flash fc6571425a version: bump 0.3.1 -> 0.3.2 (the alktunnels 0.5.0 dependency-patch ride, pre-release) 2026-11-03 18:07:23 +00:00
glm-5.3-flash fcacedb21e dep: alktunnels 0.4.1 -> 0.5.0 (the correlation-gate release)
- Additive upstream API only (the forwarded-op establisher variant,
  register_tunnel_forwarded_openable_with_establisher + ForwardedGate);
  no alkcall bump (0.10.1 rides on), no wire change, and no change on
  this crate's paths: our composed accept keeps the base
  register_tunnel_forwarded_openable byte-for-byte.

Verification: cargo test (default + composed: 145/148 lib tests, all
suites green), cargo clippy --all-targets -D warnings (default +
composed), cargo fmt --check, cargo check/clippy wasm32-unknown-unknown.
2026-11-03 18:04:58 +00:00
115 changed files with 3618 additions and 3568 deletions

No files matched your search

+41 -41
View File
@@ -37,10 +37,10 @@ docs/architecture/
The README is the entry point. It contains:
1. **Current State** — what phase the project is in, what's implemented
2. **Architecture Documents** — table linking to each spec doc with status
3. **ADR Table** — every decision with number, title, and status
4. **Open Questions** — link to `open-questions.md`
1. **Current State**: what phase the project is in, what's implemented
2. **Architecture Documents**: table linking to each spec doc with status
3. **ADR Table**: every decision with number, title, and status
4. **Open Questions**: link to `open-questions.md`
### Spec Documents
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs)
```
ADR numbering starts at 001 within each project. ADRs are stable — once
ADR numbering starts at 001 within each project. ADRs are stable, so once
Accepted, they don't revert. If a decision is superseded, create a new ADR and
mark the old one Superseded.
@@ -97,12 +97,12 @@ don't need ADRs.
`open-questions.md` contains all unresolved questions across all spec documents,
organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
- **Origin** — which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved
- **Priority** — high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs
- **OQ-ID** (OQ-01, OQ-02, ...): stable reference
- **Origin**: which spec doc(s) the question appeared in
- **Status**: open, resolved, partially resolved
- **Priority**: high, medium, low
- **Resolution**: when resolved, what was decided and which ADR addresses it
- **Cross-references**: related OQs and ADRs
Spec documents reference OQs by number, not by repeating the question inline.
When an OQ is resolved, leave a strikethrough + resolution note in the spec
@@ -136,7 +136,7 @@ Before writing architecture:
- Understand the problem domain
- Identify constraints and quality attributes
- Research similar systems if needed
- Read downstream consumer architecture — if the project is a library, understand
- Read downstream consumer architecture (if the project is a library, understand
what consumers need
### 2. Identify Documentation Scope
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
number. As you identify open questions, add them to `open-questions.md` and
reference them by OQ-ID.
Spec documents reference ADRs and OQs — they don't contain the full rationale
Spec documents reference ADRs and OQs; they don't contain the full rationale
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
open questions tracked centrally.
@@ -180,7 +180,7 @@ Before requesting external review:
blocking condition (for `deferred(scope)`) or investigation target
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
deferring. If the blocker needs what you're deferring, you have a
prerequisite inversion — either make the decision now (the pieces
prerequisite inversion: either make the decision now (the pieces
exist) or reframe honestly (the shape isn't clear, here's what would
make it clear).
@@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made:
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
2. Create a blocker task in `tasks/architecture/` naming the dependency
3. Continue to decisions that *can* be made — do not stall on one question
3. Continue to decisions that *can* be made; do not stall on one question
### 6. Request Architecture Review
@@ -216,12 +216,12 @@ task(
Address feedback:
- **Critical**: Must fix before stabilization — inline decisions not
- **Critical**: Must fix before stabilization: inline decisions not
extracted, ADR references that point to nonexistent files, undefined
terms, circular deferrals
- **Warning**: Should fix — missing cross-references, documents approaching
- **Warning**: Should fix; missing cross-references, documents approaching
split threshold
- **Suggestion**: Consider — minor clarity improvements
- **Suggestion**: Consider minor clarity improvements
Iterate until zero critical issues.
@@ -264,7 +264,7 @@ last_updated: 2026-05-29
answer is resolved, not left "open" with hedging language like "v1 default"
or "can be revisited later." If the decision is made, mark it resolved. If
the decision genuinely can't be made yet (the use case isn't concrete,
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below.
the options aren't clear), mark it `deferred(scope)` (see Safe Exit below).
The architect's job is to make architecture decisions that *can* be made
and to clearly identify which decisions *can't* be made yet and why.
@@ -272,7 +272,7 @@ last_updated: 2026-05-29
Door type classifies **reversal cost** (one-way vs two-way), not
urgency. A two-way door is a decision you make now and can revert
later — not a decision to defer. Using "it's a two-way door" as a
later, not a decision to defer. Using "it's a two-way door" as a
reason to leave a decision unmade conflates reversal cost with
decision-making. See ADR-009 §"What this framework is NOT" for the
full rationale.
@@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type.
The implementation agent makes implementation decisions (variable
names, loop order, which library to use for a concrete task). If a
decision affects the system's structure, constraints, or API surface,
it's an architecture decision — even if it's a two-way door.
it's an architecture decision, even if it's a two-way door.
## Anti-Patterns to Avoid
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections —
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
centralize in `open-questions.md`
3. **Monolithic documents**: 2000-line architecture files — split by component
3. **Monolithic documents**: 2000-line architecture files; split by component
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
copy-paste rationale
5. **Historical comparison**: "Here's what the old code did" — specs describe
5. **Historical comparison**: "Here's what the old code did"; specs describe
the current design, not the transition from before
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR
@@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
## Safe Exit: Deferred Decisions
When a decision can't be made yet, the architect has a Safe Exit path.
This is not a failure — it's scope management. The architect's job is
This is not a failure; it's scope management. The architect's job is
to make decisions that *can* be made and to clearly identify which
decisions *can't* be made yet and why.
@@ -318,10 +318,10 @@ There are two kinds of deferral. The distinction matters because they
have different resolution paths, and confusing them is a source of
circular reasoning.
### `deferred(scope)` — the information is genuinely missing
### `deferred(scope)`: the information is genuinely missing
The decision can't be made because something the decision depends on
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC
doesn't exist yet. Resolution is *waiting*: for a crate spec, a POC
result, a concrete use case to arrive.
A decision should be `deferred(scope)` when:
@@ -336,11 +336,11 @@ A decision should be `deferred(scope)` when:
current scope is core + call crates; this question is about the agent
crate")
### `deferred(unclear)` — the pieces exist but the shape isn't clear
### `deferred(unclear)`: the pieces exist but the shape isn't clear
The pieces of the decision exist (decided in other ADRs, existing
types, existing patterns) but the composition — how they fit together
into a coherent shape — isn't clear yet. Resolution is *investigation*,
types, existing patterns) but the composition (how they fit together
into a coherent shape) isn't clear yet. Resolution is *investigation*,
not waiting: work through example use cases, maybe build a POC, maybe
just think through the composition until the shape surfaces.
@@ -352,7 +352,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
normalization) while the shape they compose into (the client config
type) is still unclear. Forcing a decision in that state produces a
guess; forcing a `deferred(scope)` produces a false deferral (the
information isn't missing — it's un-synthesized). `deferred(unclear)`
information isn't missing; it's un-synthesized). `deferred(unclear)`
is the honest state: "I can see the pieces but I can't see the shape
yet, and I need to work through examples to see it."
@@ -363,27 +363,27 @@ A decision should be `deferred(unclear)` when:
- Resolution requires *work* (thinking through examples, building a
POC), not *waiting* (for a spec or use case to arrive)
- The architect can articulate what investigation would help ("work
through 2+ example outbound-dial use cases") — if you can't, that's a
through 2+ example outbound-dial use cases"). If you can't, that's a
signal the deferral might be circular
### How to Defer
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not
`open` (implies it should be resolved now) and not `resolved`
(implies it's decided).
2. **State the blocking condition** (`deferred(scope)`) or
**investigation target** (`deferred(unclear)`) — what specific thing
**investigation target** (`deferred(unclear)`): what specific thing
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
exists" or "investigation: work through 2+ example outbound-dial use
cases (hub→worker, worker→hub) to see how verifier-selection +
provider + connector compose."
3. **State the impacts** — what does this block downstream? Be
3. **State the impacts**: what does this block downstream? Be
specific: "blocks the first hub deployment because the hub dials
workers" not "blocks the hub crate." This is the triage signal that
makes the deferral's urgency visible. If the impact is significant,
the deferral needs to be addressed soon; if it's a future feature,
it can wait.
4. **Move on** — the architect continues to decisions that *can* be
4. **Move on**: the architect continues to decisions that *can* be
made. Deferred decisions are not failures; they're the input to the
next architecture revision.
@@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when:
- **Status**: deferred(scope) | deferred(unclear)
- **Door type**: <one-way | two-way>
- **Priority**: <high | medium | low>
- **Impacts**: <what this blocks downstream — be specific>
- **Impacts**: <what this blocks downstream; be specific>
- **Blocked on**: <concrete dependency> (for deferred(scope))
**Investigation**: <what work would make the shape clear> (for deferred(unclear))
- **Resolution**: Not yet decidable. <Why — either the information
- **Resolution**: Not yet decidable. <Why: either the information
doesn't exist yet (deferred(scope)) or the pieces exist but the
composition isn't clear (deferred(unclear), cite the pieces).>
- **Cross-references**: OQ-NN, ADR-NNN
@@ -410,12 +410,12 @@ A decision should be `deferred(unclear)` when:
- Do not mark a deferred decision as `resolved` with caveats. "Resolved
with an escape hatch" is hedging.
- Do not leave a deferred decision as `open` without a blocking
condition. "Open" means "needs to be resolved now" — if it can't be
condition. "Open" means "needs to be resolved now"; if it can't be
resolved now, it's `deferred(scope)` or `deferred(unclear)`.
- Do not confuse the two deferral kinds. If the information is missing,
it's `deferred(scope)`. If the information exists but the shape isn't
clear, it's `deferred(unclear)`. Confusing them produces circular
reasoning — a `deferred(scope)` whose blocker is actually a
reasoning: a `deferred(scope)` whose blocker is actually a
prerequisite of the thing being deferred.
## When to Redirect
+16 -16
View File
@@ -84,7 +84,7 @@ Check coverage of:
#### F. Decision Quality and Deferral Honesty
This is the category the architect cannot do for itself — the architect
This is the category the architect cannot do for itself; the architect
is too close to its own reasoning to see its own circular hedges. A
fresh context can see the whole picture and spot reasoning that folds
back on itself. This is often the highest-value category on projects
@@ -97,20 +97,20 @@ Check for three cases:
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose
resolution contains temporal language or escape hatches:
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if
- "v1 default," "phase_n," "when x arrives," "can be revisited": if
the decision is made, state it cleanly. Reserve temporal language
for genuinely deferred decisions.
- "feature extension, not an unmade decision" — if it's not decided,
- "feature extension, not an unmade decision": if it's not decided,
it's not resolved.
- "additive, not blocking" — if it's not decided, don't claim it is.
- "two-way door — can be changed later if needed" — door type
classifies reversal cost, not whether a decision is made.
- "not a v1 blocker" — if it's not decided, it's deferred. Say what
- "additive, not blocking": if it's not decided, don't claim it is.
- "two-way door, can be changed later if needed" (door type
classifies reversal cost, not whether a decision is made).
- "not a v1 blocker": if it's not decided, it's deferred. Say what
unblocks it.
- "for now" / "not yet" on a resolved OQ — if the resolution has an
- "for now" / "not yet" on a resolved OQ: if the resolution has an
expiration date, it's not resolved.
- Resolution text primarily about how the decision can be changed
later ("X, but here's how we'd undo X") — the decision is made; drop
later ("X, but here's how we'd undo X"): the decision is made; drop
the undo instructions. If it's "X for now, Y later," the decision is
not made.
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
**2. False deferral / circular hedge.** A deferred OQ whose blocking
condition is a *prerequisite* of the thing being deferred, not a
blocker. This is the most damaging pattern — it creates a circular
blocker. This is the most damaging pattern; it creates a circular
dependency where the deferred thing can never resolve because its
blocker needs it.
@@ -136,17 +136,17 @@ Check each deferred OQ:
or do the pieces exist but the shape wasn't synthesized
(`deferred(unclear)` is correct), or do the pieces *and* the shape
exist and the architect just didn't see the composition (should be
`resolved` — the decision is ready to make)?
`resolved`: the decision is ready to make)?
Flag as **critical**: the deferral is circular or inverted. Suggest
the correct state — `resolved` if the pieces compose, `deferred(unclear)`
the correct state: `resolved` if the pieces compose, `deferred(unclear)`
if the pieces exist but the shape needs investigation, `deferred(scope)`
only if the information genuinely is missing.
**3. Legitimate deferral.** A deferred OQ where the information
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
but the shape genuinely needs investigation (`deferred(unclear)`).
Leave these — they're honest. The distinction from case 2 is that
Leave these; they're honest. The distinction from case 2 is that
the blocking condition or investigation target does *not* depend on
the thing being deferred.
@@ -156,12 +156,12 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
`partially resolved`) should have an **impacts** field stating what it
blocks downstream. Check:
- Is the impacts field present? Absence is a warning — without it, the
- Is the impacts field present? Absence is a warning; without it, the
deferral's urgency is invisible and triage is guesswork.
- Is it specific? "Blocks the first hub deployment because the hub
dials workers" is useful. "Blocks the hub crate" is boilerplate.
- Does it match the priority? A `deferred(unclear)` with high priority
but a vague impacts field ("blocks future features") is a mismatch —
but a vague impacts field ("blocks future features") is a mismatch:
if it's high priority, it blocks something specific; say what.
Flag missing impacts fields as **warning**, vague ones as
@@ -259,5 +259,5 @@ section 2"
- Critical issues must block stabilization
- The Decision Quality (F) category is often the highest-value check on
projects creating new protocols or solving poorly-defined problems.
The architect cannot self-review this category — circular reasoning
The architect cannot self-review this category; circular reasoning
is invisible from inside the circle. Prioritize it.
+3 -3
View File
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
## Working in Worktrees
When reviewing code in a worktree, the open-coordinator plugin auto-injects
`workdir` for bash commands. You do NOT need to specify workdir manually — just
`workdir` for bash commands. You do NOT need to specify workdir manually; just
run commands as usual.
```text
@@ -111,9 +111,9 @@ cargo fmt --check # Format check
For this project, also verify:
- No comments in code (per project convention)
- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no
- Error handling uses `anyhow::Result` (application) / `thiserror` (library); no
panics in library code
- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles
- Feature flags are used correctly (`tls`, `iroh`, `acme`); the base crate compiles
lean
- Public API is well-documented with `///` doc comments where appropriate
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
+21 -21
View File
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
## The `worktree` Tool (via @alkimiadev/open-coordinator)
You use the **worktree** tool with `{action, args}` dispatch. Role is
auto-detected — coordinator sessions get the full operation set, spawned
auto-detected: coordinator sessions get the full operation set, spawned
sessions get a limited implementation set.
### Coordinator Operations
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
If merge conflicts occur:
- **Source code conflicts between parallel tasks** that modify the same file:
Resolve them yourself. Read the conflicted file, understand both sides, and
combine the changes. Both sets of changes are valid — they were just
combine the changes. Both sets of changes are valid; they were just
developed in parallel.
- **Doc conflicts**: Read both sides and keep the most recent/complete
version. Often one branch cleaned up drift tables while another updated
@@ -112,7 +112,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
```
The `remote: true` flag tells the plugin to also delete the remote branch —
The `remote: true` flag tells the plugin to also delete the remote branch;
no separate `git push origin --delete` needed. If you need to force-remove a
dirty worktree, add `force: true`.
@@ -132,13 +132,13 @@ in this order:
latest main)
If two tasks modify the same source files and were developed in parallel, you
WILL get merge conflicts. This is expected — resolve them.
WILL get merge conflicts. This is expected. Resolve them.
### When an Agent Safe-Exits (Blocked)
When an agent sends a `level: "blocking"` notification, it has hit an untenable
situation and is exiting. This is the Safe Exit protocol — it's important, don't
ignore it.
situation and is exiting. This is the Safe Exit protocol (it's important, don't
ignore it).
1. **Read the blocking message carefully.** The agent should have included what
it was trying to do, what went wrong, what it tried, and suggested
@@ -162,7 +162,7 @@ ignore it.
```
4. **Try to resolve the blocker:**
- Missing context? Send it via `worktree({action: "message", ...})` — but
- Missing context? Send it via `worktree({action: "message", ...})`, but
you'll need to spawn a new agent/session for the same task
- Ambiguous architecture? Ask the user to clarify
- Scope too large? Decompose into smaller tasks
@@ -179,14 +179,14 @@ ignore it.
The `prompt` parameter supports `{{task}}` template substitution. Use it, but
also include:
1. **Task identification** — How to find their task file in `tasks/`
2. **Merge from main** — Tell them to
1. **Task identification**: How to find their task file in `tasks/`
2. **Merge from main**: Tell them to
`git fetch origin && git merge origin/main --no-edit` before starting, since
main may have advanced since their worktree was created
3. **Key references** — Which source files and architecture docs to read
4. **Project constraints** — Important rules from the repo (no comments,
3. **Key references**: Which source files and architecture docs to read
4. **Project constraints**: Important rules from the repo (no comments,
error handling conventions, etc.)
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete
5. **Done signal**: Use `worktree({action: "notify", ...})` when complete
Example prompt template:
@@ -200,7 +200,7 @@ Your task: {{task}}
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
4. Implement the changes, following all acceptance criteria.
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main.
6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
7. Push: git push origin $(git branch --show-current)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
When spawning parallel tasks, check if they modify overlapping source files.
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
cause merge conflicts. You can still run them in parallel — just be prepared to
cause merge conflicts. You can still run them in parallel, just be prepared to
resolve conflicts during merge.
If you want to avoid conflicts, make overlapping tasks sequential. But parallel
@@ -255,7 +255,7 @@ worktree({action: "spawn", args: {
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
}})
# Review tasks — often handle yourself
# Review tasks: often handle yourself
# If level: review, verify the acceptance criteria against the codebase
# directly instead of spawning a new agent
```
@@ -266,7 +266,7 @@ worktree({action: "spawn", args: {
The notification system works well. When an agent completes, you receive a
notification in your session. When an anomaly is detected, you receive an alert.
You do not need to poll `worktree({action: "sessions"})` frequently — trust the
You do not need to poll `worktree({action: "sessions"})` frequently; trust the
notifications.
Check `worktree({action: "sessions"})` when:
@@ -312,7 +312,7 @@ Use these when:
When a task has `level: review`, verify the acceptance criteria yourself instead
of spawning a new agent. Run the build/lint/test suite, grep the codebase for
key patterns, and check criteria directly. Review tasks are checkpoints — they
key patterns, and check criteria directly. Review tasks are checkpoints, so they
don't produce code changes.
Only spawn a review task as an agent if the review requires extensive manual
@@ -321,7 +321,7 @@ inspection of many files.
## Task File Handling
Task files (`tasks/*.md`) are coordination state. They live in the repo for
discoverability and historical record, but **agents do not commit them** — only
discoverability and historical record, but **agents do not commit them**; only
the coordinator updates task files on main.
### Why Agents Don't Commit Task Files
@@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires:
what happened
2. Send guidance via `worktree({action: "message", ...})` if you can help
3. Abort via `worktree({action: "abort", ...})` if unrecoverable
4. Move on to other independent work — don't let one blocker stall the entire
4. Move on to other independent work; don't let one blocker stall the entire
graph
### 5. Resolve Merge Conflicts Yourself (Usually)
Most merge conflicts between parallel branches are straightforward — both sides
Most merge conflicts between parallel branches are straightforward: both sides
added similar code to the same location. Read the conflicts, combine both sets
of changes, validate, and commit. Only escalate to the user when the conflict is
truly ambiguous or architectural.
@@ -418,7 +418,7 @@ After merging and pushing:
```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
```
The `remote: true` flag handles remote branch deletion automatically — no
The `remote: true` flag handles remote branch deletion automatically; no
separate `git push origin --delete` needed.
Don't let stale branches accumulate.
+26 -26
View File
@@ -10,7 +10,7 @@ graph.
## Your Environment
**You are in a worktree.** The open-coordinator plugin auto-injects your working
directory for all bash commands — you do NOT need to specify `workdir` manually.
directory for all bash commands, so you do NOT need to specify `workdir` manually.
**Verify your worktree (optional):**
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
worktree. This means:
```bash
# ✅ CORRECT — workdir is auto-injected
# ✅ CORRECT: workdir is auto-injected
cargo test
# ✅ ALSO CORRECT — explicit workdir still works
# ✅ ALSO CORRECT: explicit workdir still works
bash({ command: "cargo test", workdir: "/path/to/worktree" })
```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing.
## Workflow
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
### 5. Commit and Notify
```bash
# Stage only source code — NOT task files
# Stage only source code, NOT task files
git add src/ test/ docs/ # or specific files as appropriate
git commit -m "feat(<task-id>): <description>"
git push origin $(git branch --show-current)
@@ -178,7 +178,7 @@ When task becomes untendable:
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
```
3. **Commit any partial source code progress** if it's coherent (you may not
have any — that's fine)
have any; that's fine)
4. **Push your branch** so the coordinator can inspect your work if needed
5. **Exit** - coordinator handles escalation
@@ -194,13 +194,13 @@ If NOT in worktree:
When available, use memory tools to manage your context:
- `memory({tool: "context"})` — check context window usage, especially during
- `memory({tool: "context"})`: check context window usage, especially during
long implementations
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous
- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
assistant messages if you lose track
- `memory({tool: "search", args: {query: "..."}})` — search past conversations
- `memory({tool: "search", args: {query: "..."}})`: search past conversations
for relevant context
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a
- `memory_compact()`: compact at natural breakpoints (e.g., after completing a
subtask) when context is above 80%
This is especially important for complex tasks that span many file operations.
@@ -209,62 +209,62 @@ This is especially important for complex tasks that span many file operations.
Read `AGENTS.md` at project root for full details. Key rules:
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`)
1. **No comments in code**: per project convention. Doc comments (`///`, `//!`)
are fine and expected on public API.
2. **Error handling** — `thiserror` for library error types. No panics in
2. **Error handling**: `thiserror` for library error types. No panics in
library code. No `unwrap()` or `expect()` outside tests. 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. Use `tokio::sync`
3. **`tokio` is the async runtime**: all I/O is async. Use `tokio::sync`
primitives (`oneshot`, `mpsc`) for request correlation and subscription
channels; `parking_lot` for short-held internal locks (`PendingRequestMap`).
4. **No secret material on the wire** — `call.requested`/`call.responded`
4. **No secret material on the wire**: `call.requested`/`call.responded`
payloads and `OperationContext.metadata` carry no private keys, API keys, or
decrypted credentials. Outbound credentials flow through `Capabilities`
injected at the assembly layer → `HandlerRegistration.capabilities` →
`OperationContext.capabilities` → handler.
5. **No-env-vars invariant** — no handler reads outbound credentials from any
5. **No-env-vars invariant**: no handler reads outbound credentials from any
source other than `OperationContext.capabilities`. This is a spec-level
invariant, not a runtime convention.
6. **`OperationEnv` must remain a trait** — the trait-based design enables
6. **`OperationEnv` must remain a trait**: the trait-based design enables
registry layering (session overlays, connection overlays, peer-keyed
composition). Do not make it concrete or hardcode the global registry.
7. **Wire formats are stable** — `EventEnvelope` (`{ type, id, payload }` +
7. **Wire formats are stable**: `EventEnvelope` (`{ type, id, payload }` +
length-prefixed JSON framing) and the channels 8-byte chunk header
(`[channel_id:u32 BE][length:u32 BE][payload]`) are one-way doors. New event
types may be added; existing shapes must not change.
8. **Producer/consumer, not server/client** — both sides of a call or channels
8. **Producer/consumer, not server/client**: both sides of a call or channels
connection can initiate. Use "producer"/"consumer" or "accept side"/"connect
side," not "server"/"client."
9. **Vendored core types** — `Connection`, `ProtocolHandler`, `BiStream`,
9. **Vendored core types**: `Connection`, `ProtocolHandler`, `BiStream`,
`BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`,
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
`StreamError` live in this crate. Do not add a separate `alkcore` dependency.
Keep them lean (no TLS, no transport coupling, no endpoint/accept-loop).
10. **BAST documents for wire formats** — every binary wire format carries a
10. **BAST documents for wire formats**: every binary wire format carries a
BAST (Binary Abstract Syntax Tree) document as its machine-readable spec
(e.g. the channels chunk header's `docs/architecture/chunk-header.bast.json`,
embedded as `CHUNK_HEADER_BAST`). BAST is plain JSON — no dependency
embedded as `CHUNK_HEADER_BAST`). BAST is plain JSON; no dependency
required to author or consume it. The `alktype` crate compiles BAST into
readers/writers/validators; future codegen derives language-specific
implementations. Trivial or hot-path formats (chunk header, tty framing)
stay hand-rolled with the BAST doc as the contract; complex formats (sftp)
use the alktype engine or codegen. Do not roll your own offset map or
validator for complex formats.
11. **Feature flags** — transports may be feature-gated if the need arises. The
11. **Feature flags**: transports may be feature-gated if the need arises. The
base crate should compile lean (no `quinn`, no `iroh` unless the feature is
on). Verify both `cargo test` (default) and `cargo test --all-features` pass
if features are added.
12. **Abort cascades to descendants** — `call.aborted` for a parent cascades to
12. **Abort cascades to descendants**: `call.aborted` for a parent cascades to
all non-terminal descendants. Default `abort-dependents`;
`continue-running` opt-in. The composing handler decides the child's policy,
not the wire caller.
13. **Peer authorization via `AccessControl`** — a remote peer's call is
13. **Peer authorization via `AccessControl`**: a remote peer's call is
authorized by `AccessControl::check(peer_identity)`. No `remote_safe` flag,
no `trusted_peer` bypass. `Visibility::Internal` ops are never wire-callable.
14. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
14. **Naming conventions**: 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
15. **Module structure**: one module per file under `src/`, re-exported from
`src/lib.rs`. Public API surface is `lib.rs` re-exports.
## Key Principles
+4 -4
View File
@@ -10,10 +10,10 @@ approaches.
## Your Environment
**You are in a research worktree.** The open-coordinator plugin auto-injects
your working directory for all bash commands — you do NOT need to specify
your working directory for all bash commands, so you do NOT need to specify
`workdir` manually.
- The current directory IS the worktree — do NOT navigate elsewhere
- The current directory IS the worktree; do NOT navigate elsewhere
- You are on branch `research/<task-id>`
- Use relative paths for all file operations
@@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without
specifying workdir:
```bash
# ✅ CORRECT — workdir is auto-injected
# ✅ CORRECT: workdir is auto-injected
cargo test
```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing.
## When You Are Spawned
+108 -90
View File
@@ -17,7 +17,7 @@ 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
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
@@ -25,20 +25,20 @@ The workflow:
5. `git push origin main`
6. Report the commit hash and the verification summary
Exceptions — **do not** commit or push without asking:
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
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
reject it, fix the issue and create a new commit; do not amend the
failed one.
Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`). Do
@@ -46,13 +46,13 @@ 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
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
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
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
@@ -66,54 +66,54 @@ implementation agents.
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
on pump completion, since `try_join!` alone deadlocks; see alkcall
ADR-050's `pump_bidi` / alknet ADR-078").
2. **Error handling** — `thiserror` for library error types; map SOCKS5
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
you reach for `unwrap`, the error path wasn't specified, so 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
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
4. **WASM target is load-bearing (by default)**, meaning 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:
(`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features
= ["full"]`**; and keep socket/platform I/O feature-gated. Note:
`fast-socks5` uses `tokio::net` and `socket2` unconditionally as
published; the posture is the **targeted fork** (OQ-SK-04,
resolved 2026-09-20 — convention 17; extraction pinned 2026-09-21,
resolved 2026-09-20, convention 17; extraction pinned 2026-09-21,
ADR-011 §Amendment; fork 2026-09-22, ADR-013): the `T`-generic
subset was extracted in-tree — the kernel-net handlers are not
copied — so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by
subset was extracted in-tree (the kernel-net handlers are not
copied), so the RFC 1928 core (`src/rfc1928/`) is wasm-clean by
construction with no `net`-shaped feature; socket I/O rides
`local` alone. Run the wasm check whenever a non-backend
module changes; it is a hard gate (no dependency-graph failure is
expected — the core contains no `tokio::net`).
expected, since the core contains no `tokio::net`).
5. **Wire format is stable** — the SOCKS5 protocol itself is RFC 1928
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
JSON object identifying the produced resource (alkcall ADR-039,
whose `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
6. **Producer/consumer, not server/client**, because 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
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
@@ -121,32 +121,32 @@ implementation agents.
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
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<T, ...>`,
`transfer(inbound, outbound)`); the wrapper must preserve that
genericity — the channels adapter feeds the SOCKS5 state machine a
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
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
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
upstream as alkcall `channels::pump_bidi` (ADR-050, review 007 R-03), so
use it; do not hand-roll the two-pump shape. The channels layer
drops all per-channel senders on transport EOF, so 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 —
shutdown. Await `pump_bidi` inline inside the `OpenHandler`'s task, because
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.
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
@@ -154,32 +154,32 @@ implementation agents.
assembly-layer shapes on either side: the producer may serve from a
real listener (a feature-gated local backend), and the consumer may
expose the resource on a local port (the ssh `-D` front door that
vanilla clients — curl, browsers, tun2proxy — point at). The
vanilla clients (curl, browsers, tun2proxy) point at). The
protocol itself never binds. 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
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
10. **Backpressure and limits are inherited, not redefined**: bounded
per-channel buffers, the 256-channel default caps (per-connection
memory bound, ADR-040; per-identity DoS defense, ADR-041 — policy
memory bound, ADR-040; per-identity DoS defense, ADR-041, i.e. policy
knobs, configurable without a wire change; the `u32` wire space
itself is ~4 billion), 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
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`,
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.10.x — breaking changes are expected at this major-zero stage;
v0.10.x, where 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.10.1"` and bump
deliberately. The establishment surface (alkcall ADR-049 +
@@ -187,21 +187,21 @@ implementation agents.
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`; 0.8.0 adds the optional reply projection —
`with_reply_field(s)`, ADR-049 amendment 3). The 0.7.0 identity
`Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose payloads
must be `Send + Sync`; 0.8.0 adds the optional reply projection,
`with_reply_field(s)` from ADR-049 amendment 3). 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
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
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
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
@@ -210,45 +210,45 @@ implementation agents.
producing side; whatever ACL governs the socks5 resource governs
everything reachable through it).
13. **Feature flags** — substrate backends are feature-gated if the
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/
14. **Naming** follows the 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
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
adapter (the producer half, the 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
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.
published; decide via ADR before the first consumer.
17. **Upstream: alkcall is ours to shape; fast-socks5 is forked
in-tree.**
alkcall is ours (the actively co-developed substrate): file asks
early and land them there rather than working around them locally —
early and land them there rather than working around them 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; the graduation
sweep landed in alkcall 0.8.0 / alktunnels 0.2.0). `fast-socks5`
is NOT ours and never will be — it is third-party upstream
is NOT ours and never will be; it is third-party upstream
(`/workspace/fast-socks5` is a read-only reference checkout,
v1.0.0, MIT), but the `T`-generic subset we depend on is now
**ours, as a targeted fork** (OQ-SK-04 resolved 2026-09-20 as the
inline-vendor extraction; extraction pinned 2026-09-21, ADR-011
§Amendment; fork posture pinned 2026-09-22, ADR-013): the subset
lives in `src/rfc1928/` as fully owned code — project conventions
lives in `src/rfc1928/` as fully owned code, and project conventions
apply to it unconditionally (no vendor carve-outs, no
lint-allow posture, no verbatim preservation). The extraction
cut (ADR-011's table) is the hand-port map: if a future need
@@ -256,12 +256,30 @@ implementation agents.
vanilla-server example), port the piece from the reference
checkout, adapt it to the conventions, and let the differential
suite guard it. A future upstream fix that matters is a
hand-port too — nothing is absorbed silently, and nothing waits
hand-port too; nothing is absorbed silently, and nothing waits
on upstream. Keep `/workspace/fast-socks5` for differential
testing of RFC edge cases. Provenance is permanent: the module
docs and the bundled license notice (`src/rfc1928/LICENSE`)
always name the origin (v1.0.0, the extraction range, the fork
point) — extend, never replace, the notice.
point): extend, never replace, the notice.
18. **No em dashes in prose.** Do not use the em dash (U+2014) in source
doc comments, markdown docs (including all of `docs/` and `tasks/`),
agent prompts under `.opencode/agents/`, or commit messages.
Restructure instead: split the sentence, use parentheses, commas, a
colon, or a semicolon, whichever reads naturally, and vary the
choice rather than converting every instance the same way. A spaced
hyphen (` - `) is not an acceptable substitute; it reads as a
different but equally recognizable writing tell. Inside fenced code
blocks the rule applies to prose-y content only: line comments,
ASCII-diagram label separators, and template placeholders are
rewritten too, while wire data, JSON/BAST examples, and tool
transcripts survive verbatim. The same rule covers en dashes used as
clause separators, though en dashes inside numeric ranges keep their
normal meaning. Hyphens in compound words (`wasm-clean`,
`fast-socks5`, `alk/socks5`) are untouched. The one exemption is
`docs/reviews/`: reviews are archived historical records, so their
prose is preserved verbatim and is excluded from the em-dash greps.
## Verification Commands
@@ -282,8 +300,8 @@ cargo publish --dry-run --allow-dirty # before a release
### Release steps (after the version bump + verification)
1. Commit the release (bump + CHANGELOG/README/AGENTS pins).
2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z — <the tag>"`
— **annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated
2. Tag it: `git tag -a vX.Y.Z -m "release: vX.Y.Z (<the tag>)"`
**annotated**, always (v0.1.0/v0.2.0 are lightweight; annotated
since the v0.3.0 release, 2026-10-19).
3. `git push origin main && git push origin vX.Y.Z`.
4. `cargo publish` (no `--allow-dirty` once the tree is clean).
@@ -293,51 +311,51 @@ The corpus replay is the fuzz gate (the alkcall §7.9 posture): it
replays every committed seed through the same invariant functions the
fuzz targets run, on stable, without nightly. Campaigns (nightly,
cargo-fuzz) run manually via `fuzz/run-detached.sh` when
`src/wire.rs`, `src/rfc1928/`, or `src/params.rs` change — never as a
`src/wire.rs`, `src/rfc1928/`, or `src/params.rs` change, never as a
foreground child of an agent session (docs/research/fuzzing.md §5).
The wasm check is the structural guard for the "default crate is
wasm-clean" invariant (same as alktty/alktunnels) — run it whenever a
wasm-clean" invariant (same as alktty/alktunnels); run it whenever a
non-backend module changes. It is a hard gate: the RFC 1928 core is
an extraction with no kernel-net code (ADR-011 §Amendment), so no
dependency-graph failure is expected.
## Architecture Context
- `docs/research/phase-0.md` — the Phase 0 (Exploration) document and
- `docs/research/phase-0.md` holds 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
- **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
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
- **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.9.x, crates.io). The
- **alkcall** (`/workspace/@alkdev/alkcall`, v0.9.x, 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. 0.8.0 adds the establisher
reply projection (`with_reply_field`, ADR-049 amendment 3),
flavor-form open-op ids in discovery (ADR-047 amendment 3), and
the in-tree `ChannelRelay`/`HubLegTemplate` (ADR-051) — the hub
the in-tree `ChannelRelay`/`HubLegTemplate` (ADR-051), the hub
re-produce machinery the SOCKS5 hub story composes on.
- **alktunnels** — v0.2.0 (ADRs 007/008): the direct op
(`channels/tunnel/direct` — dynamic-target egress; OQ-SK-01
- **alktunnels** (v0.2.0, ADRs 007/008): the direct op
(`channels/tunnel/direct`, dynamic-target egress; OQ-SK-01
Option A's vehicle), the bind-first establisher + forwarded op
(the composed BIND substrate; OQ-SK-08 variant 2), and
`TunnelListener`.
- **fast-socks5** — `/workspace/fast-socks5` (v1.0.0, MIT,
third-party upstream — NOT ours, never will be). The SOCKS5
- **fast-socks5** (`/workspace/fast-socks5`, v1.0.0, MIT,
third-party upstream, NOT ours and never will be). The SOCKS5
implementation the RFC 1928 core forked: explicit typestate
server API (`Socks5ServerProtocol<T, states::*>`), generic over
`T: AsyncRead + AsyncWrite + Unpin`; `run_tcp_proxy` /
@@ -346,48 +364,48 @@ dependency-graph failure is expected.
and `Socks5Datagram` (UDP associate). The posture is the
**targeted fork** (OQ-SK-04 resolved 2026-09-20 as the
inline-vendor extraction; extraction pinned 2026-09-21, ADR-011
§Amendment; fork 2026-09-22, ADR-013 — convention 17): the
§Amendment; fork 2026-09-22, ADR-013, convention 17): the
`T`-generic subset lives in `src/rfc1928/` as owned code, no
kernel-net code and no `net` feature; BIND and the `new_reply`
publication are the two carried changes; the reference checkout
stays for differential testing of RFC edge cases.
- **alknet's SOCKS5 client** —
- **alknet's SOCKS5 client** at
`/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs`
(ADR-090): the client-side example need — `Socks5UdpSocket`
(ADR-090), the client-side example: `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::
`/workspace/noq`, which has `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
- 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
- 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
- alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a
`BidiStreamSource`; every handler receives a `Connection`
- alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` /
- 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
ALPN-specific, i.e. 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 —
- alkcall ADR-042: hub relay; alktunnels' hub-proxy refinement, in which
the hub terminates and re-produces per-hop ACL (a SOCKS5 resource
is the same shape, "further downstream" — alktunnels
is the same shape, "further downstream", per alktunnels
phase-0-findings §Prior art)
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving
- 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
@@ -398,5 +416,5 @@ dependency-graph failure is expected.
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
against, the TODO is stale; remove it and align with the ADR. Do not
implement the rejected design.
+66 -36
View File
@@ -6,6 +6,35 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.3.2] - 2026-11-03
The dependency-patch release: the alktunnels sibling bumped 0.4.1 →
0.5.0 (the correlation-gate release). No alkcall bump (0.10.1 rides
on), no alksocks API change, no wire change, and no change on this
crate's paths: the composed accept stays on the base
`register_tunnel_forwarded_openable` (kept byte-for-byte upstream), so
the new serving-side establishment phase is an adoption question, not
a bump requirement. Patch rather than minor: the dependency bump is
semver-compatible (additive API only), matching the 0.3.1 precedent.
### Changed
- **alktunnels 0.4.1 → 0.5.0** (crates.io). The correlation-gate
release (routed from alkssh's downstream experience): the forwarded
op's serving half gains an establishment phase:
`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate`
+ `ForwardedPlan`, so a stale/missing `listen` token fails the open
as a typed `channel:open_failed` with
`details.reason == "unknown_resource"` BEFORE any channel is born
(extending alkcall ADR-049's posture to the forwarded op's serving
side). Additive public API only; the wire delta is empty and the
base `register_tunnel_forwarded_openable` stays byte-for-byte.
Adoption in this crate: available to the composed accept's
`register_forwarded_serving` (the correlation registry is assembly
side, so a `ForwardedGate` over the token map is the adoption
shape), but not taken in this bump; the base registration remains
the compiled path and the residual-race fallback posture.
## [0.3.1] - 2026-10-22
The dependency-patch release: the alkcall substrate bumped 0.10.0 →
@@ -29,7 +58,7 @@ dependency bumps are semver-compatible (0.10.x → 0.10.1, 0.4.x →
park-drain are one atomic section; no await runs inside it. Relevant
in principle to this crate's reverse-path adopt flow (the
bind-first establisher surfaces channels through the same adopt
path), though no change in this crate's own surfaces was needed —
path), though no change in this crate's own surfaces was needed:
the fix surface is private (`ChannelState`), the behavior only
strengthens. No API, wire-format, or documented-cap behavior change
upstream.
@@ -37,9 +66,9 @@ dependency bumps are semver-compatible (0.10.x → 0.10.1, 0.4.x →
0.10.1). No alktunnels API/wire change; the composed providers see
none of it.
- **fuzz/shared harness pin** (the only file-level contacts are the
`Cargo.toml`s/`Cargo.lock` — plus the doc pins): the harness declares
`Cargo.toml`s/`Cargo.lock`, plus the doc pins): the harness declares
its own `alkcall` pin (it constructs `ChannelOpenError` values for
the invariant functions), so the two pins must stay in lockstep —
the invariant functions), so the two pins must stay in lockstep:
bumped 0.10.0 → 0.10.1 with the substrate (a drifting pin produces a
duplicate-type compile failure, not silent drift).
@@ -61,10 +90,10 @@ semver-visible to downstream consumers (matching the alktunnels
- **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway
ownership-seam release (review 012): `GatewayDispatch` gains
`with_ownership_provider` / `with_static_ownership_fallback`, and
the previously-silent static fallback becomes loud — an unwired
the previously-silent static fallback becomes loud: an unwired
spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op
whose spec carries `access_control.resource_type`. None of it is on
this crate's paths: alksocks does not use the gateway module —
this crate's paths: alksocks does not use the gateway module;
SOCKS5 opens serve via `ChannelCore::register_openable`/
`register_openable_with_establisher` registry dispatch on the native
wire, which 0.10.0 leaves untouched. All of this crate's alkcall
@@ -79,7 +108,7 @@ semver-visible to downstream consumers (matching the alktunnels
- **fuzz/shared harness pin** (the only bump contact): the harness
declares its own `alkcall` pin (it constructs `ChannelOpenError`
values for the invariant functions), so the two pins must stay in
lockstep — bumped 0.9.0 → 0.10.0 with the substrate (a drifting pin
lockstep, bumped 0.9.0 → 0.10.0 with the substrate (a drifting pin
produces a duplicate-type compile failure, not silent drift).
## [0.2.0] - 2026-10-14
@@ -106,7 +135,7 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride).
`RelayError::PubTypedOpen` removal, the
`register_imported`/`register_imported_all` return change,
`Establishment` dropping its `Debug` derive) is untouched by this
crate's API use — `Establishment::default()` is the only
crate's API use: `Establishment::default()` is the only
`Establishment` contact, in the session gate. 145 default / 171
all-features tests pass; wasm-clean verified.
- **alktunnels 0.2.1 → 0.3.0** (the dependency-major ride on alkcall
@@ -116,19 +145,19 @@ consumers (matching the alktunnels 0.2.1 → 0.3.0 ride).
## [0.1.0] - 2026-09-27
The initial release: SOCKS5 (RFC 1928) as a produced resource on
alkcall channels — the `-D` flavor as a first-class protocol crate,
alkcall channels, the `-D` flavor as a first-class protocol crate,
over alkcall 0.8.1. The defining posture: the protocol never binds a
port; kernel binds exist only as explicit, feature-gated assembly
shapes. Composed against by the alktunnels 0.2.1 graduation (the
direct op + the bind-first/forwarded flow power the composed
providers). Fuzzing adopted (the `fuzz/` workspace — excluded from
providers). Fuzzing adopted (the `fuzz/` workspace, excluded from
the published package) over the crate's two attacker-reachable
parsers: the ASSOCIATE datagram codec and the RFC 1928 handshake;
campaigns clean, no crate bugs.
### Added
- **The RFC 1928 core (`src/rfc1928/`, ADR-013)** — the targeted fork
- **The RFC 1928 core (`src/rfc1928/`, ADR-013)**: the targeted fork
of fast-socks5 v1.0.0's `T`-generic subset, extracted in-tree as
fully owned code (MIT notice bundled; extraction per ADR-011): the
wire consts, codecs, errors, the explicit typestate server API
@@ -136,78 +165,78 @@ campaigns clean, no crate bugs.
`T: AsyncRead + AsyncWrite + Unpin`), and the client `use_stream`
path. Two deliberate changes carried from the fork point: BIND
support in the base (ADR-007) and the `new_reply` publication. No
kernel-net code — the default crate is wasm-clean by construction.
- **The wire codec (`wire`, ADR-003)** — the ASSOCIATE datagram frame
kernel-net code; the default crate is wasm-clean by construction.
- **The wire codec (`wire`, ADR-003)**: the ASSOCIATE datagram frame
(`[len: u16 BE][addr + data]`), framing applied exactly once at
this layer; the `DatagramReader` incremental parser; the
`MAX_INNER_LEN` oversize guard.
- **The wire-stable open-op surface (`params`, ADR-001/006)** — the
- **The wire-stable open-op surface (`params`, ADR-001/006)**: the
`alk/socks5` ALPN, the `channels/socks5/sub` op id, the trivial
`{}` params (the RFC conversation is the data plane; nothing to
negotiate at open time), and the two scope constants
(`socks5:open`, `socks5:bind` — two grants, never implied). The
(`socks5:open`, `socks5:bind`; two grants, never implied). The
typed open-error vocabulary rides `error` (the ADR-049 reasons).
- **The three seams (`egress`, ADR-004)** — `DialFn` /
- **The three seams (`egress`, ADR-004)**: `DialFn` /
`EgressFactory` / `BindAcceptFactory`: functions, not traits, at
the three I/O planes; ownership rides the handle structurally
(`TargetHandle` / `BindTargetHandle` / `BindAccept`); the egress
readiness contract (`next_reply` `Pending` when nothing is ready)
is load-bearing API.
- **The producer half (`producer`)** — the session-gate establisher
- **The producer half (`producer`)**: the session-gate establisher
(validates the opener identity; the target is unknown at open
time), the command-dispatch pump handler (CONNECT pass-through /
ASSOCIATE datagram stage / BIND phase model — reply#1 → quiet wait
ASSOCIATE datagram stage / BIND phase model: reply#1 → quiet wait
→ exactly one accept → reply#2 → pass-through), the command gate
(`socks5:bind` at BIND dispatch), the identity witness, and both
wiring shapes: the free functions
(`register_socks5_openable` / `socks5_session_establisher` /
`make_socks5_pump_handler` / `serve_socks5_over`) and the builder
(`Socks5Producer::builder()` — dial/egress/accept/bind_policy).
(`Socks5Producer::builder()`: dial/egress/accept/bind_policy).
Data planes ride `alkcall::channels::pump_bidi` (ADR-050).
- **The consumer half (`consumer`)** — the typed channels-native
- **The consumer half (`consumer`)**: the typed channels-native
sessions, each owning its channel's teardown (`Drop` aborts +
reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT — raw
reaps; no `Clone`): [`Socks5ChannelSession`] (CONNECT: raw
pass-through, the `split`/`into_inner` postures),
[`AssociateSession`] (the framed datagram stage —
[`AssociateSession`] (the framed datagram stage:
`send_datagram`/`recv_datagram`/`split`), [`BindSession`] (the
reply#1/reply#2 reader + the `BindDataPlane` pass-through), and the
token-carrying surface (`TokenClient` + the `*_with_token`
constructor twins — the cred-bearing front door's re-origination).
- **The `local` feature (ADR-008/009)** — the socket-backed seam
providers (`tcp_dial` / `udp_egress` / `bind_accept` — the explicit
constructor twins: the cred-bearing front door's re-origination).
- **The `local` feature (ADR-008/009)**: the socket-backed seam
providers (`tcp_dial` / `udp_egress` / `bind_accept`: the explicit
bind decisions, resolving via the non-blocking
`tokio::net::lookup_host` resolver), plus both doors: the
consumer-side front doors (`FrontDoor` — the NoAuth raw pipe + the
consumer-side front doors (`FrontDoor`: the NoAuth raw pipe + the
RFC 1929 cred-bearing door with the credential→identity mapping,
ADR-005; `UdpFrontDoor` — one association per bound relay socket,
ADR-005; `UdpFrontDoor`: one association per bound relay socket,
the RFC codec at the door boundary only, the peer-pin posture: the
client's source IP pins on the first parsed datagram, replies
follow the latest-seen port from that IP, foreign-IP datagrams are
dropped per RFC 1928's MUST — OQ-SK2-09) and the producer-side
listener (`ProducerDoor` — vanilla clients dial it, each accepted
dropped per RFC 1928's MUST; OQ-SK2-09) and the producer-side
listener (`ProducerDoor`: vanilla clients dial it, each accepted
connection is served via `serve_socks5_over`; the ADR-014
idle-first-byte window + max-sessions cap knobs).
- **The `composed` feature (ADR-008/009)** — the alktunnels 0.2.1
composed providers: `direct_egress` (the direct-op egress — a lazy
- **The `composed` feature (ADR-008/009)**: the alktunnels 0.2.1
composed providers: `direct_egress` (the direct-op egress: a lazy
destination→tunnel table, `DEFAULT_MAX_TUNNELS` bounded), and
`bindfirst_accept` + `register_forwarded_serving` (the BIND accept
whose listener lives on a bind-first listen resource; the
`CorrelationRegistry` correlates the forwarded op's peer metadata
into `ForwardedSession`s).
- **The idle-timeout posture (ADR-014)** — every phase
- **The idle-timeout posture (ADR-014)**: every phase
teardown-governed, no protocol-layer idle bound; the doors own the
accept-path defense.
- **The suites** — the differential suite against the upstream
- **The suites**: the differential suite against the upstream
reference checkout (`tests/rfc1928-differential.rs`), the
protocol-core integration suite (`tests/core-integration.rs`), and
the assembly-layer integration suite (`tests/assembly-integration.rs`).
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — the
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: the
alkcall §5 pattern adopted for this crate's two attacker-reachable
parser classes: `datagram_codec` (the `wire` codec — no-panic,
parser classes: `datagram_codec` (the `wire` codec: no-panic,
infallible decode, size bound, re-frame round-trip, the
`retained_bytes` bound) and `rfc1928_codec` (the RFC 1928 handshake
state machine, fed attacker bytes from both protocol directions),
plus `params_parse`. Invariant logic lives in `fuzz/shared/` — a
plus `params_parse`. Invariant logic lives in `fuzz/shared/`: a
stable-toolchain crate replayed as plain `cargo test` (the corpus
replay is the fuzz gate). 129 committed seeds; detached campaigns
clean (`oom/timeout/crash: 0/0/0`, coverage saturated). The one
@@ -218,7 +247,8 @@ campaigns clean, no crate bugs.
[`AssociateSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html
[`BindSession`]: https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html
[Unreleased]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.0...HEAD
[Unreleased]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.1...HEAD
[0.3.2]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.1...v0.3.2
[0.3.1]: https://git.alk.dev/alkdev/alksocks/compare/v0.3.0...v0.3.1
[0.3.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.alk.dev/alkdev/alksocks/compare/v0.1.0...v0.2.0
+9 -9
View File
@@ -1,6 +1,6 @@
[package]
name = "alksocks"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
@@ -29,14 +29,14 @@ composed = ["dep:alktunnels"]
[dependencies]
# The substrate (convention 11): pinned, bump deliberately, never
# transitively. 0.10.1: the adoption-window ordering fix (review 013,
# routed from alkgit's wave-2 gate) — install+park-drain is one atomic
# routed from alkgit's wave-2 gate): install+park-drain is one atomic
# section in the channels demux; the fix surface is private upstream, so
# no change on this crate's paths (relevant in principle to the
# bind-first adopt flow; the behavior only strengthens).
alkcall = "0.10.1"
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
# The fork's declared logging seam (rfc1928.md: upstream `log` calls
# were dropped or re-pointed here); no source file references it yet —
# were dropped or re-pointed here); no source file references it yet,
# the first consumer is expected to be the producer layer's
# diagnostics (review 001 N-2).
tracing = "0.1"
@@ -45,17 +45,17 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
futures = { version = "0.3", default-features = false, features = ["std"] }
# The sibling crate (the composed egress's direct-op vehicle — ADR-008/
# The sibling crate (the composed egress's direct-op vehicle, ADR-008/
# ADR-009): rides `composed` alone. Pinned; bump deliberately
# (convention 11's deliberate-bump rule applies to siblings too).
# 0.4.1: the dependency-patch ride on alkcall 0.10.1, no alktunnels
# API/wire change.
alktunnels = { version = "0.4.1", optional = true }
# 0.5.0: the correlation-gate release (the forwarded op's serving half
# gains an establishment phase (additive API only, no wire change).
alktunnels = { version = "0.5.0", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The composed suite's topology (the alktunnels direct-op producer) —
# The composed suite's topology (the alktunnels direct-op producer);
# dev-only: the test binary gates itself on the `composed` feature
# (`#![cfg(feature = "composed")]`), the dev-dep keeps it compiling.
alktunnels = "0.4.1"
alktunnels = "0.5.0"
+28 -70
View File
@@ -2,7 +2,7 @@
SOCKS5 (RFC 1928) as a produced resource on
[alkcall](https://crates.io/crates/alkcall) channels: the `ssh -D`
(dynamic / SOCKS5 proxy) flavor as a first-class protocol crate —
(dynamic / SOCKS5 proxy) flavor as a first-class protocol crate;
the sibling of [alktty](https://crates.io/crates/alktty) (terminals)
and [alktunnels](https://crates.io/crates/alktunnels) (`-L`/`-R`
tunnels). The RFC 1928 conversation rides inside a channels data
@@ -11,15 +11,15 @@ the producing side.
The defining posture: **the protocol never binds a port unless
explicitly configured to.** Kernel binds exist only as explicit,
feature-gated assembly shapes on either side — the consumer may
expose the resource on a local port (the front door vanilla clients —
curl, browsers, tun2proxy — point at), and the producer may serve
feature-gated assembly shapes on either side. The consumer may
expose the resource on a local port, the front door vanilla clients
(curl, browsers, tun2proxy) point at, and the producer may serve
from a real listener. A producer/consumer protocol crate, no
transport dependencies; the transport is whatever alkcall runs over.
## Quick start
### Producer — register the SOCKS5 open op
### Producer: register the SOCKS5 open op
```rust
use std::sync::Arc;
@@ -49,11 +49,11 @@ let op_registry = Arc::new(OperationRegistry::new());
// session-gate establisher checks the opener identity, and the pump
// handler drives the RFC 1928 state machine in-stream over the
// channel's `BiStream`. Wire `core` + `op_registry` into your
// channels serving stack — this call is the socks5-specific step:
// channels serving stack; this call is the socks5-specific step:
producer.register(&core, &op_registry, AuthContext::anonymous(b"alk/socks5"), None)?;
```
### Consumer — open a SOCKS5 channel (CONNECT)
### Consumer: open a SOCKS5 channel (CONNECT)
```rust
use alkcall::channels::client::ChannelClient;
@@ -64,7 +64,7 @@ let client: ChannelClient = /* your alk/channels connection */;
// One session = one channel = one SOCKS5 session. The RFC handshake
// runs in-stream over the channel; a refused target surfaces the RFC
// reply code (typed errors, no phantom channel — the adopted channel
// reply code (typed errors with no phantom channel: the adopted channel
// is torn down before the error returns).
let mut session = Socks5ChannelSession::connect(
&client,
@@ -75,14 +75,14 @@ let (mut read, mut write) = session.split().expect("pipe");
// ... raw pass-through: the channel IS the target connection ...
```
### Consumer — UDP ASSOCIATE (the framed datagram stage)
### Consumer: UDP ASSOCIATE (the framed datagram stage)
```rust
use alksocks::AssociateSession;
use alksocks::rfc1928::util::target_addr::TargetAddr;
// One channel per association; datagrams ride `[len: u16 BE]
// [addr + data]` frames in-band (ADR-003) — the RFC codec exists at
// [addr + data]` frames in-band (ADR-003); the RFC codec exists at
// the front doors only.
let mut assoc = AssociateSession::associate(&client).await?;
let dest = TargetAddr::Domain { addr: "dns.internal".into(), port: 53 };
@@ -90,14 +90,14 @@ assoc.send_datagram(&dest, b"query").await?;
let (src, payload) = assoc.recv_datagram().await?.expect("a datagram");
```
### Consumer — BIND (the reply#1/reply#2 reader)
### Consumer: BIND (the reply#1/reply#2 reader)
```rust
use alksocks::BindSession;
use alksocks::rfc1928::util::target_addr::TargetAddr;
// Requires the `socks5:bind` grant on the producing side (a separate
// capability from `socks5:open` — never implied, ADR-006). The
// capability from `socks5:open`; never implied, ADR-006). The
// session drives the phase model: reply#1 → quiet wait → exactly one
// accept → reply#2 → raw pass-through (ADR-007).
let mut bind = BindSession::bind(&client, TargetAddr::Ip("0.0.0.0:0".parse()?)).await?;
@@ -105,7 +105,7 @@ let reply1 = bind.reply1().await?; // the BND.ADDR the producer bound
// ... hand reply1's address to the peer, await the accept + reply#2 ...
```
### Consumer — the front door (vanilla clients)
### Consumer: the front door (vanilla clients)
```rust
use std::sync::Arc;
@@ -127,15 +127,15 @@ let door = FrontDoor::builder()
door.serve(client).await?;
```
### Producer — the local listener shape (the other door)
### Producer: the local listener shape (the other door)
```rust
use alksocks::producer::Socks5Producer;
// Feature `local`: serve a produced SOCKS5 resource from a real
// listener — vanilla clients dial it, each accepted connection is
// listener; vanilla clients dial it, each accepted connection is
// served via `serve_socks5_over` (the NoAuth in-stream RFC
// conversation; the seams dial locally). The bind is explicit — the
// conversation; the seams dial locally). The bind is explicit: the
// builder requires it; the protocol layer never binds.
let producer = Socks5Producer::builder()
.dial(alksocks::local::tcp_dial())
@@ -154,8 +154,8 @@ both sides of a connection can be either; role follows the resource):
| Role | Via `alk/channels` |
|------|--------------------|
| **Producer** (dials targets, serves the RFC conversation) | [`Socks5Producer`](https://docs.rs/alksocks/latest/alksocks/producer/struct.Socks5Producer.html) / [`register_socks5_openable`](https://docs.rs/alksocks/latest/alksocks/producer/fn.register_socks5_openable.html) — the open op (`channels/socks5/sub`) with the session-gate establisher and the command-dispatch pump; the RFC 1928 typestate core drives negotiation, command read, and replies in-stream over the channel's `BiStream`; CONNECT/BIND data planes ride [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
| **Consumer** (wants the proxy) | [`Socks5ChannelSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.Socks5ChannelSession.html) (CONNECT) / [`AssociateSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html) (ASSOCIATE) / [`BindSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html) (BIND) — typed channels-native sessions, each owning its channel's teardown |
| **Producer** (dials targets, serves the RFC conversation) | [`Socks5Producer`](https://docs.rs/alksocks/latest/alksocks/producer/struct.Socks5Producer.html) / [`register_socks5_openable`](https://docs.rs/alksocks/latest/alksocks/producer/fn.register_socks5_openable.html): the open op (`channels/socks5/sub`) with the session-gate establisher and the command-dispatch pump; the RFC 1928 typestate core drives negotiation, command read, and replies in-stream over the channel's `BiStream`; CONNECT/BIND data planes ride [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
| **Consumer** (wants the proxy) | [`Socks5ChannelSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.Socks5ChannelSession.html) (CONNECT) / [`AssociateSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.AssociateSession.html) (ASSOCIATE) / [`BindSession`](https://docs.rs/alksocks/latest/alksocks/consumer/struct.BindSession.html) (BIND): typed channels-native sessions, each owning its channel's teardown |
One connection hosts many SOCKS5 sessions (one per channel); sessions
are independent. Both roles are callable from either connection side
@@ -164,12 +164,12 @@ client/server roles keep their RFC names inside the protocol layer.
### The RFC conversation is the data plane
The open op's params are deliberately trivial (`{}`, ADR-001) —
The open op's params are deliberately trivial (`{}`, ADR-001);
there is nothing to negotiate at open time (the SOCKS5 target is
unknown until the RFC command arrives). Everything else is RFC 1928,
in-stream:
- **CONNECT**: raw pass-through, 0 B overhead — the channel's halves
- **CONNECT**: raw pass-through, 0 B overhead; the channel's halves
are the target connection (the two-pump shape).
- **ASSOCIATE**: the reply's BND.ADDR is a pure RFC handle (the
sentinel in-band); datagrams ride `[len: u16 BE][addr + data]`
@@ -182,7 +182,7 @@ in-stream:
Authorization is two grants, never implied (ADR-006): the open gate
(`socks5:open`) at the registry, the command gate (`socks5:bind`) at
BIND dispatch. The target policy is the dialer (ADR-012): the
producer's dial/egress/accept closures are the security boundary —
producer's dial/egress/accept closures are the security boundary;
whatever they allow is what the resource reaches.
### The seams
@@ -191,70 +191,28 @@ The protocol layer is substrate-agnostic by construction (ADR-004):
`DialFn` / `EgressFactory` / `BindAcceptFactory` are *functions, not
traits* at the three I/O planes; ownership rides the handle
structurally. A channels `BiStream`, a `TcpStream`, or an in-process
pipe drops in behind the same seams — no socket type leaks into the
pipe drops in behind the same seams; no socket type leaks into the
protocol layer.
### WASM target
The default crate (no features) compiles to `wasm32-unknown-unknown`
— the protocol layer for a sandboxed TS/Python adapter. The RFC 1928
The default crate (no features) compiles to `wasm32-unknown-unknown`,
the protocol layer for a sandboxed TS/Python adapter. The RFC 1928
core is a targeted fork of
[fast-socks5](https://github.com/dizda/fast-socks5) v1.0.0's
`T`-generic subset, extracted in-tree as fully owned code
(`src/rfc1928/`, MIT notice bundled; ADR-011/013): no kernel-net
code, no `net` feature — the socket I/O rides `local` alone, and the
code, no `net` feature; the socket I/O rides `local` alone, and the
default build is wasm-clean by construction.
## Features
| Feature | Contents | wasm |
|---------|----------|------|
| *(default)* | the RFC 1928 core (`src/rfc1928/` — codecs, typestate, client `use_stream`), the ASSOCIATE wire codec, the open-op spec, the three seams, the producer + consumer session types — protocol only | yes |
| *(default)* | the RFC 1928 core (`src/rfc1928/`: codecs, typestate, client `use_stream`), the ASSOCIATE wire codec, the open-op spec, the three seams, the producer + consumer session types; protocol only | yes |
| `local` | the socket-backed seam providers ([`tcp_dial`](https://docs.rs/alksocks/latest/alksocks/local/fn.tcp_dial.html) / [`udp_egress`](https://docs.rs/alksocks/latest/alksocks/local/fn.udp_egress.html) / [`bind_accept`](https://docs.rs/alksocks/latest/alksocks/local/fn.bind_accept.html)), the front doors ([`FrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.FrontDoor.html) + [`UdpFrontDoor`](https://docs.rs/alksocks/latest/alksocks/door/struct.UdpFrontDoor.html), the consumer-side exposure; [`ProducerDoor`](https://docs.rs/alksocks/latest/alksocks/local/producer_door/struct.ProducerDoor.html), the producer-side listener) | no |
| `composed` | the alktunnels-composed providers: [`direct_egress`](https://docs.rs/alksocks/latest/alksocks/composed/fn.direct_egress.html) (the direct-op egress, a lazy destination→tunnel table) + [`bindfirst_accept`](https://docs.rs/alksocks/latest/alksocks/composed/fn.bindfirst_accept.html) (the BIND accept whose listener lives on a bind-first listen resource) | yes |
## Version 0.1
The first release rides alkcall 0.8.1 (the establisher reply
projection is not consumed yet — the open op's params are trivial;
the 0.8.1 duplicate-adopt fix is directly relevant to the consumer
open/adopt paths) and alktunnels 0.2.1 (the direct op + the
bind-first/forwarded flow power the composed providers). The
`fuzz/` workspace (excluded from the published package) fuzzes the
two attacker-reachable parser classes — the ASSOCIATE datagram codec
and the RFC 1928 handshake state machine. Wire-stable surface: the
`alk/socks5` ALPN, the `channels/socks5/sub` open op with `{}` params,
and the two scope constants — all pinned by ADRs 001–014.
0.2.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
the four registered-but-always-failing stubs became real
implementations; none were on this crate's paths) and the alktunnels
bump 0.2.1 → 0.3.0. No alksocks API change, no wire change, zero code
contact with the 0.9.0 breaking surface; minor rather than patch
contact with the 0.9.0 breaking surface; minor rather than patch
because the semver-incompatible dependency bumps + this crate's
alkcall type re-exports make it semver-visible downstream.
0.3.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway
ownership-seam release — `GatewayDispatch` ownership-provider
threading and the loud unwired-spine default; the gateway module
isn't on this crate's paths, registry dispatch on the native wire is
untouched) and the alktunnels bump 0.3.0 → 0.4.0 (the dependency-major
ride on alkcall 0.10.0). No alksocks API change, no wire change, zero
code contact with the 0.10.0 breaking surface (the only contact is the
fuzz/shared harness's own alkcall pin — a mechanical lockstep bump);
minor rather than patch for the same semver-visible-dependency reason
as 0.2.0.
0.3.1 rides the alkcall bump 0.10.0 → 0.10.1 (the
adoption-window ordering fix — a chunk routed inside the demux's
install→drain window could be delivered ahead of earlier-parked
early arrivals, inverting byte-stream order; relevant in principle
to the bind-first adopt flow) and the alktunnels bump 0.4.0 → 0.4.1
(its dependency-patch ride). The fix surface is private upstream, so
there is no change on this crate's paths; patch rather than minor
because both dependency bumps are semver-compatible.
## Verification
```bash
@@ -268,11 +226,11 @@ cargo check --target wasm32-unknown-unknown # the wasm-clean guard
## Documentation
- [Architecture docs](docs/architecture/README.md) — the
- [Architecture docs](docs/architecture/README.md): the
authoritative spec set (in the repository, not the published
package): ADRs (001–014), the wire format, the producer/consumer
contracts, and the fork provenance.
- [API docs](https://docs.rs/alksocks) — full crate documentation on
- [API docs](https://docs.rs/alksocks): full crate documentation on
docs.rs.
## License
+35 -35
View File
@@ -4,15 +4,15 @@ last_updated: 2026-09-25 (the assembly layer landed + the cross-cutting
assembly integration suite; the protocol core landed + the cross-cutting
integration suite 2026-09-24; the rfc1928 doc's targeted fork 2026-09-22; the
vendor extraction amendment ADR-011 §Amendment 2026-09-21; Phase 1
initial draft 2026-09-20 — the OQ-SK ledger promoted into ADRs
initial draft 2026-09-20; the OQ-SK ledger promoted into ADRs
001-012 and open-questions.md)
---
# alksocks — Architecture
# alksocks: Architecture
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 — the `alk/socks5`-family
unless explicitly configured to. It is the `alk/socks5`-family
producer/consumer protocol crate, the sibling of alktty
(`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels
realizes the `-L`/`-R` forwarding flavors, alksocks realizes the
@@ -21,34 +21,34 @@ architecture spec docs and the ADRs.
## Current State
Phase 1 (Architecture) — the **protocol core and the assembly layer
Phase 1 (Architecture): the **protocol core and the assembly layer
are implemented**. Phase
0 (`docs/research/phase-0.md`) is converged (2026-09-14) and
POC-complete (all numbered POCs passed or were dropped with the scope
decision); the OQ-SK ledger's Phase 1 inputs are collected and
promoted into ADRs 001–014. All docs are `draft` — they transition
promoted into ADRs 001–014. All docs are `draft`; they transition
to `reviewed` when their open questions resolve and the architecture
review passes.
The landed surface: the RFC 1928 core in `src/rfc1928/` (the targeted
fork, ADR-013 — the vendor group's six tasks landed and passed the
fork, ADR-013; the vendor group's six tasks landed and passed the
extraction review), the ASSOCIATE datagram frame codec (`src/wire.rs`,
ADR-003), the open-op params + typed error surface (`src/params.rs` /
`src/error.rs`, ADR-001), the three seam types (`src/egress.rs`,
ADR-004), the producer half (`src/producer.rs` — the session-gate
ADR-004), the producer half (`src/producer.rs`: the session-gate
establisher, the command-dispatch pump, the three commands, the scope
gates), and the consumer half (`src/consumer.rs` — the
gates), and the consumer half (`src/consumer.rs`: the
connect/associate/bind session types with teardown ownership). The
assembly layer (batch 2) landed with it: the `local` providers
(`src/local/` — tcp_dial / udp_egress / bind_accept, the producer-side
`ProducerDoor`, ADR-008/009), the front doors (`src/door.rs` — the
assembly layer (batch 2) landed with it: the `local` providers in
`src/local/` (tcp_dial / udp_egress / bind_accept, the producer-side
`ProducerDoor`, ADR-008/009), the front doors in `src/door.rs` (the
NoAuth raw-pipe + RFC 1929 cred-bearing TCP door, the BIND door, the
UDP door, ADR-005/003/014), and the composed providers
(`src/composed/` — the direct-op egress + the bind-first accept over
UDP door, ADR-005/003/014), and the composed providers in
`src/composed/` (the direct-op egress + the bind-first accept over
alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites
pin the behavior end to end: `tests/core-integration.rs` (the
protocol core) and `tests/assembly-integration.rs` (the assembly
layer — the full `-D` story through the doors, the composed variant
layer: the full `-D` story through the doors, the composed variant
set, mixed assembly, the assembly teardown matrix).
## Documents
@@ -56,33 +56,33 @@ set, mixed assembly, the assembly teardown matrix).
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, feature gates, module map |
| [wire.md](wire.md) | draft | The wire format: the open op (trivial params — ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels |
| [wire.md](wire.md) | draft | The wire format: the open op (trivial params, ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels |
| [producer.md](producer.md) | draft | The producer half: the session-gate establisher, command dispatch, the three seams (dial/egress/accept), scope gates, the local backend |
| [consumer.md](consumer.md) | draft | The consumer half: the channels-native sessions (connect/associate/bind), the front doors, door auth (ADR-005), teardown ownership |
| [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011) — the fork's scope, the deliberate modifications, the maintenance posture |
| [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011), covering the fork's scope, the deliberate modifications, the maintenance posture |
| [bast.md](bast.md) | draft | The BAST document for the ASSOCIATE datagram frame (ADR-003); pass-through stages have no binary framing to describe |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.. — the SK2 numbering is not contiguous from 01, working-draft numbers were consumed) |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.., where the SK2 numbering is not contiguous from 01 because working-draft numbers were consumed) |
## ADR Table
| ADR | Title | Status |
|-----|-------|--------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN, Open Op, and Trivial Params | Accepted |
| [002](decisions/002-protocol-base.md) | Protocol Base — the Vendored fast-socks5 Typestate Subset | Accepted |
| [003](decisions/003-udp-handle-swap.md) | The Handle Swap — one Channel per Association, Frames In-Band | Accepted |
| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape — Function, not Trait, at Three Seams | Accepted |
| [005](decisions/005-auth-split-by-door.md) | Auth Mapping — "Both," Split by the Door | Accepted |
| [006](decisions/006-access-control.md) | Access-Control Posture — the Open Gate Plus the Command Gate | Accepted |
| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate — the Phase Model Extended | Accepted |
| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) | Accepted |
| [009](decisions/009-feature-gates.md) | Feature-Gate Layout — `local` and the Composition Boundary | Accepted (amended 2026-09-21) |
| [010](decisions/010-channel-capacity.md) | Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change | Accepted |
| [011](decisions/011-inline-vendor.md) | the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) |
| [002](decisions/002-protocol-base.md) | Protocol Base: the Vendored fast-socks5 Typestate Subset | Accepted |
| [003](decisions/003-udp-handle-swap.md) | The Handle Swap: one Channel per Association, Frames In-Band | Accepted |
| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape: Function, not Trait, at Three Seams | Accepted |
| [005](decisions/005-auth-split-by-door.md) | Auth Mapping: "Both," Split by the Door | Accepted |
| [006](decisions/006-access-control.md) | Access-Control Posture: the Open Gate Plus the Command Gate | Accepted |
| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate: the Phase Model Extended | Accepted |
| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op) | Accepted |
| [009](decisions/009-feature-gates.md) | Feature-Gate Layout: `local` and the Composition Boundary | Accepted (amended 2026-09-21) |
| [010](decisions/010-channel-capacity.md) | Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change | Accepted |
| [011](decisions/011-inline-vendor.md) | the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) |
| [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted |
| [013](decisions/013-targeted-fork.md) | The Targeted Fork — the Extracted Subset Is Owned, Not Vendored | Accepted |
| [013](decisions/013-targeted-fork.md) | The Targeted Fork: the Extracted Subset Is Owned, Not Vendored | Accepted |
Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own
numbers and are NOT ported — the alkcall originals at
numbers and are NOT ported; the alkcall originals at
`/workspace/@alkdev/alkcall/docs/architecture/decisions/` remain
authoritative for the shared surfaces (establishment, channels wire
format, `pump_bidi`, identity seam, the channel caps).
@@ -98,7 +98,7 @@ format, `pump_bidi`, identity seam, the channel caps).
(the handle swap, ADR-003).
2. **The RFC conversation is the data plane.** One ALPN, one open
op, trivial params (ADR-001); the phase model needs no mode byte
— the reply synchronizes the switch; no in-band control
(the reply synchronizes the switch); no in-band control
vocabulary (the escape hatch is a new ALPN, never a framing
change).
3. **The seams are functions, not traits** (ADR-004): DialFn /
@@ -109,7 +109,7 @@ format, `pump_bidi`, identity seam, the channel caps).
seam; the front door = RFC 1929 + the credential→identity static
mapping.
5. **Two grants, clean separation** (ADR-006): `socks5:open`
(egress) and `socks5:bind` (ingress listeners) — never implied by
(egress) and `socks5:bind` (ingress listeners), never implied by
each other; target policy = the dialer.
6. **Backpressure and limits are inherited, not redefined** (the
alkcall channels invariants); the one-channel-per-session
@@ -117,14 +117,14 @@ format, `pump_bidi`, identity seam, the channel caps).
posture is ADR-010 (config, not wire).
7. **Wasm-clean default crate** (ADR-009): the default crate is
protocol-only; the RFC 1928 core is an extraction with zero
kernel-net code — there is no `net` feature (ADR-011 §Amendment);
kernel-net code; there is no `net` feature (ADR-011 §Amendment);
`local` is opt-in and non-wasm; `composed` rides alktunnels'
wasm-clean default (verified with the wasm check at
implementation).
8. **The RFC 1928 core is owned** (ADR-013, superseding ADR-011's
vendor posture): the extracted `T`-generic subset is a targeted
fork (`src/rfc1928/`, fork point `5b18d4d`) — project
conventions apply unconditionally, the lint-allow/verbatim
fork (`src/rfc1928/`, fork point `5b18d4d`); project
conventions apply unconditionally, and the lint-allow/verbatim
apparatus is dissolved; `/workspace/fast-socks5` stays the
differential-testing behavioral reference and the only place the
full upstream surface (the kernel-net region) exists; nothing
@@ -152,5 +152,5 @@ format, `pump_bidi`, identity seam, the channel caps).
- Siblings: alktty (`/workspace/@alkdev/alktty`),
alktunnels (`/workspace/@alkdev/alktunnels`)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT,
third-party — the differential-testing reference)
third-party; the differential-testing reference)
- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`)
+19 -19
View File
@@ -3,11 +3,11 @@ status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# alksocks — BAST Document for the `alk/socks5` Wire Format
# alksocks: BAST Document for the `alk/socks5` Wire Format
This document is the **BAST (Binary Abstract Syntax Tree)**
specification for the binary portions of the `alk/socks5` wire
format — per the alk* family BAST convention (alktunnels AGENTS.md
format, per the alk* family BAST convention (alktunnels AGENTS.md
convention 12, carried from alktty; alktunnels `bast.md` is the
precedent), the mandatory ASSOCIATE
datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is
@@ -28,36 +28,36 @@ feeds it.
## Scope
The `alk/socks5` wire format is: a trivial JSON open op on the
channels call plane (a UTF-8 JSON object — `{}`; specified in
channels call plane (a UTF-8 JSON object, `{}`; specified in
[wire.md](wire.md) §The Open Op and
[ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane
inside the channel `BiStream` that is *mostly* RFC 1928 wire rules
plus raw pass-through.
Per the BAST format spec, a BAST document describes **binary data
layouts** — not JSON payloads. The RFC 1928 conversation itself
layouts**, not JSON payloads. The RFC 1928 conversation itself
(negotiation, command, replies, the RFC UDP datagram header at front
doors) is specified by RFC 1928 and implemented by the forked
RFC 1928 codecs ([rfc1928.md](rfc1928.md)) — it is not re-specified
RFC 1928 codecs ([rfc1928.md](rfc1928.md)); it is not re-specified
here. This document covers exactly the alksocks-specific binary
framing:
1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`) —
1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`),
the one framed stage (ADR-003). CONNECT's pass-through and BIND's
pass-through have no framing for BAST to describe.
What this BAST deliberately does **not** cover:
- **The RFC 1928 wire rules** — method negotiation, command/reply
- **The RFC 1928 wire rules**: method negotiation, command/reply
encoding, the `addr` sub-layout (ATYP + addr + port), the RFC UDP
datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The
RFC is normative; the forked codecs implement it.
- **The open-op params JSON shape** (`{}`) — UTF-8 JSON on the call
- **The open-op params JSON shape** (`{}`): UTF-8 JSON on the call
plane; ADR-001.
- **The channels 8-byte chunk header** — the channels layer's own
- **The channels 8-byte chunk header**: the channels layer's own
framing (alknet ADR-071/093); stripped transparently before the
alksocks data plane sees bytes. Specified upstream, not here.
- **The EOF sentinel** — the channels-level zero-length chunk
- **The EOF sentinel**: the channels-level zero-length chunk
(upstream); the alksocks codecs never emit it (the F-2 invariant).
## The BAST document
@@ -81,13 +81,13 @@ What this BAST deliberately does **not** cover:
}
```
(Note: `addr + data` together are exactly `len` bytes — the two
(Note: `addr + data` together are exactly `len` bytes; the two
`encoding: length-prefixed` fields are the *decoder's* internal
split of one frame's payload; on the wire `len` covers both, and
`data`'s length is `len − len(addr)`, decodable because the ATYP
determines the address length. The two-field split documents the
decoder's contract, not extra wire bytes. **Joint constraint:** a
frame is legal only when `len(addr) + len(data) ≤ 65535` — the
frame is legal only when `len(addr) + len(data) ≤ 65535`; the
per-field maxima are not independent (a 65507-byte datagram to a
255-char domain would need 65766 frame bytes and must be rejected
`Oversize` at frame time; wire.md §The Data Plane carries the
@@ -95,7 +95,7 @@ arithmetic).)
## Annotations
### `AssociateFrame` — binary, exact
### `AssociateFrame`: binary, exact
Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2
bytes), then exactly `len` bytes = `addr + data`. The `addr`
@@ -109,10 +109,10 @@ One frame per datagram, in both directions of the channel
`BiStream`, for the whole datagram stage (association lifetime =
channel lifetime). Frames are self-delimiting on the byte stream; a
decoder is an incremental state machine across chunk boundaries
(boundary preservation is mandatory — an empty datagram is a legal
(boundary preservation is mandatory; an empty datagram is a legal
zero-length payload, never EOF; alktunnels F-2's invariant, met
structurally and stated at payload level: `len == 0` — zero inner
bytes — is unreachable from a compliant peer, since `addr` is always
structurally and stated at payload level: `len == 0`, zero inner
bytes, is unreachable from a compliant peer, since `addr` is always
≥ 7 bytes).
### Layering note
@@ -120,7 +120,7 @@ bytes — is unreachable from a compliant peer, since `addr` is always
The frame codec sits in the wire layer, applied **exactly once**:
`Egress::next_reply` returns *inner* bytes (`addr + data`); the
producer's frame writer adds the single length prefix (ADR-003's
framing-once invariant — the double-framing bug class POC #2
framing-once invariant; the double-framing bug class POC #2
caught). The consumer side mirrors it: the session's send frames
once, its reader state persists across receives. The BAST describes
the wire inside the `BiStream` only; `pump_bidi` (ADR-050) is
@@ -130,10 +130,10 @@ upstream's, not re-specified here.
- [ADR-003](decisions/003-udp-handle-swap.md) (the handle swap + the
frame), [ADR-001](decisions/001-alpn-and-open-op.md) (the JSON
open-op shape — outside BAST scope),
open-op shape, outside BAST scope),
[ADR-011](decisions/011-inline-vendor.md) (the forked codecs)
- [wire.md](wire.md) (the normative prose; §The Data Plane)
- alktunnels `bast.md` (the BAST precedent — same meta-schema, same
- alktunnels `bast.md` (the BAST precedent: same meta-schema, same
"binary framing only" scope rule; the length codec's original
document) and alktunnels ADR-003 (the codec alksocks reuses)
- POC #2 `poc-udp-associate-findings.md` (the frame's validation
+31 -31
View File
@@ -1,23 +1,23 @@
---
status: draft
last_updated: 2026-09-25 (the cred-bearing door's call-connection
contract reconciled — TokenClient's door-only posture, one command per
accepted connection; the BIND spent posture reconciled — the
contract reconciled: TokenClient's door-only posture, one command per
accepted connection; the BIND spent posture reconciled: the
take-guarded reply#2 reader, review 002 C-1/D-1; the landed session
signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20)
---
# alksocks — The Consumer Half
# alksocks: The Consumer Half
The consumer opens SOCKS5 channels against a produced service and
speaks RFC 1928 inside them. Two consumption shapes, structurally
separate (the Phase 0 scope resolution):
1. **The channels-native session** — a typed client that opens
1. **The channels-native session**: a typed client that opens
`channels/socks5/sub` and drives the RFC conversation in-stream
(wrapper-aware; the POC #1 `use_stream` / POC #2 associate
session shapes).
2. **The front door** (optional, feature `local`) — a real local
2. **The front door** (optional, feature `local`): a real local
endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla
clients point at; the door bridges each accepted
connection/socket onto a channel, with the RFC codec living only
@@ -33,35 +33,35 @@ Opens `channels/socks5/sub` via `ChannelClient::open_channel`
(alkcall ADR-037/043), then drives the client side of the RFC
conversation in-stream over the channel's `BiStream`:
- the forked client typestate's `use_stream` path — generic over
- the forked client typestate's `use_stream` path, generic over
the backing stream (POC #1: `Socks5Stream` impls
`AsyncRead + AsyncWrite` itself, so the session over a `BiStream`
is the intended use, not a hack);
- target construction via `ToTargetAddr` (IP-string forms parse to
IP targets; domain form passes through unresolved — ADR-012);
- after the reply, the session is a raw byte pipe — the consumer
IP targets; domain form passes through unresolved, ADR-012);
- after the reply, the session is a raw byte pipe: the consumer
reads/writes payload bytes directly (0 B framing, CONNECT).
### The associate session
The UDP ASSOCIATE client (POC #2's shape):
- opens the channel, negotiates, sends ASSOCIATE, reads the reply —
- opens the channel, negotiates, sends ASSOCIATE, reads the reply;
**receives the sentinel `BND.ADDR` and never uses it for
addressing** (the handle is the channel; ADR-003);
- `send_datagram(dst, bytes)` frames once
(`[len: u16 BE][addr + data]`, the RFC 1928 address encoding) and
writes the frame;
- `recv_datagram()` returns `Some((source_addr, bytes))` per
datagram — its incremental reader state (mid-frame position +
datagram; its incremental reader state (mid-frame position +
batched-datagram pending queue) **persists across calls** (a
datagram split across channel chunks survives a caller-driven
receive loop; POC #2's finding);
- `shutdown()` half-closes the stream — the mux's implicit-EOF
- `shutdown()` half-closes the stream; the mux's implicit-EOF
sentinel reaches the producer's stage as EOF and the association
ends (association lifetime = channel lifetime);
- **teardown ownership**: the session owns the channel; on Drop it
closes/joins — the alktunnels ADR-005 posture (the consumer
closes/joins (the alktunnels ADR-005 posture: the consumer
session type owns teardown), combined with ADR-004's
ownership-rides-the-handle contract.
@@ -72,16 +72,16 @@ client `request()` reads reply#1; reply#2 is hand-read with the
forked public `read_address` codec (reply#1's codec applied again;
no second codec). The session then rides the pass-through data
plane. The reply#2 phase is take-guarded: the one normative reader
is `await_inbound` — the first read consumes the session's stream
is `await_inbound`, and the first read consumes the session's stream
(the returned plane owns it; the session stays the teardown owner),
so a second call returns the family's spent shape (`Ok(None)`, the
same posture the sibling sessions pin) and cannot re-read the live
data plane; a failed read restores the inner (the producer ends the
session on every post-reply#1 failure exit, so a re-call EOFs —
session on every post-reply#1 failure exit, so a re-call EOFs;
never a live data-plane re-read either way). The
`reply1()` handle is a view (the advertised BND.ADDR + the raw
pre-reply#2 stream), not a reader. The BIND client is a
wrapper-aware-consumer convenience — vanilla clients reach BIND
wrapper-aware-consumer convenience; vanilla clients reach BIND
through the front door instead.
### Error surface
@@ -94,18 +94,18 @@ code surfaced through the session type.
## The Front Doors (feature `local`, optional)
The consumer's optional local exposure — the ssh `-D` shape (POC #1:
The consumer's optional local exposure, the ssh `-D` shape (POC #1:
curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the
BIND door):
- **The CONNECT/BIND door**: bind a real TCP listener; for each
accepted connection, pipe it raw onto a channel `BiStream`
(`copy_bidirectional`, **zero protocol translation** — POC #1's
(`copy_bidirectional`, **zero protocol translation**, POC #1's
literal 0 B pass-through conclusion). The door's credential half
(below) is the only RFC work it does.
- **The UDP door**: bind a real UDP socket; decode RFC-header'd
datagrams (`parse_udp_request`), send `{addr, data}` frames
in-band, return replies RFC-header'd (`new_udp_header`) — the RFC
in-band, return replies RFC-header'd (`new_udp_header`): the RFC
codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the
v1 posture).
- **The BIND door's reply#2 fidelity**: the local variant's real
@@ -118,19 +118,19 @@ BIND door):
translation; for loopback-only or wrapper-aware use.
- **The cred-bearing door** (RFC 1929): a vanilla client carrying
`user:pass` gets a real RFC 1929 conversation at the door; the
door maps the accepted credential to *local* configuration —
which channel identity/endpoint to open the producer channel
with — via a **static config table** (ADR-005: `user:pass →`
door maps the accepted credential to *local* configuration (which
channel identity/endpoint to open the producer channel with) via
a **static config table** (ADR-005: `user:pass →`
channel identity/endpoint selection). The SOCKS password never
crosses the channel as a SOCKS password; the channel identity
never reaches the vanilla client. The door checks credentials
itself (no forked `AuthMethod` impl needed — the door owns the
itself (no forked `AuthMethod` impl needed; the door owns the
accept loop). The mapped identity rides the open-op call's
`auth_token` payload (CF-005 precedence (1)) via
`TokenClient::wrap` — the client's channel-0 `CallConnection` is
`TokenClient::wrap`; the client's channel-0 `CallConnection` is
taken once per door (the door-only contract: a cred-bearing
door's client is a door client; other shapes — the UDP door's
association, channels-native sessions — ride their own
door's client is a door client; other shapes, such as the UDP door's
association and channels-native sessions, ride their own
connections to the same producer registration, one registration,
many connections). The vanilla conversation is
**one command per accepted connection** (the door's RFC half
@@ -159,10 +159,10 @@ door.serve(channel_client).await?;
```
The landed session signatures: the constructors take `&ChannelClient`
(shared — many sessions ride one client, the concurrency posture);
(shared; many sessions ride one client, the concurrency posture);
`send_datagram` takes `impl ToTargetAddr` (the CONNECT session's
target-construction posture, ADR-012); `recv_datagram()` returns
`Result<Option<(TargetAddr, Bytes)>, SocksSessionError>` — `None` is
`Result<Option<(TargetAddr, Bytes)>, SocksSessionError>`; `None` is
the channel EOF (the association's clean end), never an empty
datagram (F-2: an empty payload is a legal datagram).
@@ -185,12 +185,12 @@ None currently blocking this document.
## References
- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the
`use_stream` fit), POC #2 (`poc-udp-associate-findings.md` — the
- POC #1 (`poc-connect-wasm-findings.md`: the raw-pipe door, the
`use_stream` fit), POC #2 (`poc-udp-associate-findings.md`: the
associate session, the reader-state finding, the UDP door), POC #5
(`poc-bind-findings.md` — the reply#2 reader, the BIND door)
(`poc-bind-findings.md`: the reply#2 reader, the BIND door)
- Upstream: alkcall ADR-037/043 (channel open), **alktunnels
ADR-005** (consumer session owns teardown — the posture
ADR-005** (consumer session owns teardown; the posture
inherited), the CF-005/CF-006 seam
- [producer.md](producer.md) (the other half),
[wire.md](wire.md) (the normative wire)
@@ -9,10 +9,10 @@ Accepted (2026-09-20, Phase 1)
The crate rides alkcall channels: a produced SOCKS5 resource is an
openable ALPN-op, opened by consumers via `ChannelClient` (alkcall
ADR-037/043). The ALPN string is wire-stable once published
(AGENTS.md conventions 5/16 — decide via ADR before the first
(AGENTS.md conventions 5/16: decide via ADR before the first
consumer). Phase 0's POCs (#1, #2, #5) all ran one ALPN
(`alk/socks5`), one open op (`channels/socks5/sub`), with trivially
empty params — the natural reading of "the RFC conversation is the
empty params, the natural reading of "the RFC conversation is the
data plane": the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation), so there is nothing for params to carry
(`poc-connect-wasm-findings.md` §Wire/API notes).
@@ -20,26 +20,26 @@ in-stream post-negotiation), so there is nothing for params to carry
## Decision
- **ALPN: `alk/socks5`** (provisional → stable on first publish;
the alk/ prefix per the alkcall ADR-004 ALPN-string convention —
the alk/ prefix per the alkcall ADR-004 ALPN-string convention;
AGENTS.md convention 16's "ADR-004/ADR-006" shorthand cites the
naming family, of which ADR-004 is the operative one). One ALPN
per protocol.
- **One open op: `channels/socks5/sub`** — the SOCKS5 session op,
- **One open op: `channels/socks5/sub`**: the SOCKS5 session op,
flavor-form like alktunnels' (`…/sub`; alkcall ADR-047: openable
ALPNs are operations). All three RFC 1928 commands (CONNECT,
BIND, UDP ASSOCIATE) ride this one op, selected in-stream at
command dispatch — the command is an RFC-1928 wire fact, not a
wire-format choice of ours.
- **Params: the trivial object** `{}` — schema `{type: object}`,
additionalProperties false. Nothing else is needed at open time
(the establisher is a session gate, not a dial — the target is
command dispatch (the command is an RFC-1928 wire fact, not a
wire-format choice of ours).
- **Params: the trivial object** `{}` (schema `{type: object}`,
additionalProperties false). Nothing else is needed at open time
(the establisher is a session gate, not a dial; the target is
unknown). Extensibility: if a later need surfaces per-session
options (e.g. a requested local bind hint), that is an
**additive** params schema change while no params were ever
required; consumers that send `{}` stay valid. Params are a
self-contained JSON object identifying the produced resource
(alkcall ADR-039's shape, following alktunnels ADR-001's template).
- **Reply: `{"channel_id": <u32>}`** — the Sub-typed operation
- **Reply: `{"channel_id": <u32>}`**: the Sub-typed operation
reply, as with alktunnels. No extra reply fields in v1 (the
establishment phase is a session gate and has nothing to project;
the alkcall 0.8.0 `with_reply_field` seam remains available
@@ -49,12 +49,12 @@ in-stream post-negotiation), so there is nothing for params to carry
- The wire surface is minimal and stable: one ALPN + one op + empty
params + one reply field. The one-way-door surface is the ALPN
string, the op id, and the (trivial) params shape — all frozen at
string, the op id, and the (trivial) params shape, all frozen at
first publish.
- Command-level differentiation (CONNECT vs ASSOCIATE vs BIND)
lives in the RFC 1928 stream and in the ACL scope check at
command dispatch (see ADR-006), not in the op set. No
per-command ops — that would re-encode the RFC's own command byte
per-command ops: that would re-encode the RFC's own command byte
in our wire format for no benefit.
- POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on
the same registration (`connect_still_served_on_the_same_
@@ -62,7 +62,7 @@ in-stream post-negotiation), so there is nothing for params to carry
## References
- OQ-SK-01 (params context), OQ-SK-03 (the handle swap — why no
- OQ-SK-01 (params context), OQ-SK-03 (the handle swap, why no
correlation params), OQ-SK-08 (BIND rides the same op)
- POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding),
POC #2 `poc-udp-associate-findings.md` (one-op regression guard),
@@ -1,9 +1,9 @@
# ADR-002: Protocol Base — the Vendored fast-socks5 Typestate Subset
# ADR-002: Protocol Base: the Vendored fast-socks5 Typestate Subset
## Status
Accepted (2026-09-20, Phase 1; the extracted-and-forked posture is
OQ-SK-04's resolution — the extraction mechanics in
OQ-SK-04's resolution: the extraction mechanics in
[ADR-011](011-inline-vendor.md), the ownership posture in
[ADR-013](013-targeted-fork.md))
@@ -13,7 +13,7 @@ Phase 0 evaluated the protocol-base candidates: fast-socks5 (the
full-fidelity RFC 1928 implementation, explicit typestate server API,
generic over `T`), iroh-socks5 (structurally the closest sibling, but
iroh-coupled and an RFC subset), and a hand-rolled codec (rejected as
anti-prior-art — alknet's client hand-rolls and it is the known
anti-prior-art; alknet's client hand-rolls, and it is the known
weakness). POC #1 confirmed the load-bearing property: fast-socks5's
`Socks5ServerProtocol<T, states::*>` accepts a channels `BiStream`
directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured
@@ -24,13 +24,13 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Decision
- **The protocol layer is built on fast-socks5's explicit typestate
API** — `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/
API**: `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/
read_command/reply_success/reply_error), the client side
`Socks5Stream<S>::use_stream` / the associate session, and the
pure codecs (`new_udp_header`/`parse_udp_request`).
- **Only the explicit API is used.** The legacy
`Socks5Server`/`Socks5Socket`/`Incoming` bind-based API is
deprecated upstream and is out — never forked into the default
deprecated upstream and is out; never forked into the default
path.
- **Base = v1.0.0, extracted in-tree and forked** (ADR-011 for the
extraction mechanics and the two carried changes; ADR-013 for the
@@ -38,7 +38,7 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
`/workspace/fast-socks5` remains the read-only reference for
differential testing of RFC edge cases; the fork point is noted
in the module docs and the bundled license notice.
- **Refusals map faithfully per RFC 1928** — `ReplyError` codes
- **Refusals map faithfully per RFC 1928**: `ReplyError` codes
(`as_u8`/`from_u8`) are the error vocabulary at the command plane;
refusals are never collapsed into generic errors (AGENTS.md
convention 2). POC #1 validated the mapping (dial refusal → 0x05).
@@ -46,11 +46,11 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Consequences
- The crate owns its protocol code in-tree (the fork), so BIND and
the `new_reply` publication ride the same tree — no upstream waits.
the `new_reply` publication ride the same tree; no upstream waits.
- The typestate's interception points are the layering seams:
command-read (the dial/accept dispatch), `transfer` (the raw
two-pump), and — where the default handlers are unusable (UDP
ASSOCIATE's unconditional bind) — direct typestate driving. The
two-pump), and, where the default handlers are unusable (UDP
ASSOCIATE's unconditional bind), direct typestate driving. The
interception-point map is spec'd in [producer.md](../producer.md).
- iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF
association lifetime, FRAG≠0 refusal) were adopted at the design
@@ -1,4 +1,4 @@
# ADR-003: The Handle Swap — one Channel per Association, Frames In-Band
# ADR-003: The Handle Swap: one Channel per Association, Frames In-Band
## Status
@@ -9,12 +9,12 @@ validated end to end 2026-09-14)
UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928
replies with a UDP relay address (`BND.ADDR`/`BND.PORT`) and the
client sends datagrams to it — which seems to demand a producer-side
client sends datagrams to it, which seems to demand a producer-side
kernel bind, the one thing the protocol layer must never do.
The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled
2026-09-13): the ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is *just the
handle for the relay*, and one relay serves the whole association —
handle for the relay*, and one relay serves the whole association:
the per-datagram destination is carried in the datagram header
(ATYP/addr/port), not in the handle. The relay port was never "a port
that goes to one place"; it is a multiplexing endpoint. Swapping the
@@ -26,27 +26,27 @@ including the front door.
The in-band (channels-native) UDP ASSOCIATE shape is:
- **One channel per association** — the CONNECT shape; no second
- **One channel per association**: the CONNECT shape; no second
channel, no correlation params (the op's params stay trivial,
ADR-001).
- **Per-datagram destinations ride in-band frames:** every datagram
is `[len: u16 BE][addr + data]` — the boundary-preserving
is `[len: u16 BE][addr + data]`, the boundary-preserving
length codec shaped on alktunnels' (`frame_datagram`/
`DatagramReader`, re-implemented in first-party `src/wire.rs`;
behavior pinned by the same F-2 tests)
over the RFC address encoding (`TargetAddr::to_be_bytes()` — the
over the RFC address encoding (`TargetAddr::to_be_bytes()`, the
RFC UDP header minus RSV/FRAG). The RFC header codec
(`new_udp_header`/`parse_udp_request`) exists only at vanilla
front-door boundaries. RSV/FRAG do not ride the tunnel wire —
front-door boundaries. RSV/FRAG do not ride the tunnel wire;
fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture; it cannot even be expressed in-band).
- **Association lifetime = channel lifetime** — channel EOF is the
- **Association lifetime = channel lifetime**: channel EOF is the
end-of-life signal; no extra control vocabulary (POC #2 confirmed
post-reply garbage is refused structurally: the frame's first byte
is ATYP, so any post-reply control byte parses as a garbage ATYP
and fails the association loud).
- **The reply's `BND.ADDR`/`BND.PORT` on the in-band path are pure
RFC-compatibility surface** — the sentinel `127.0.0.1:0` (POC #2's
RFC-compatibility surface** (the sentinel `127.0.0.1:0`, POC #2's
shape). Only a front door with a real socket reports a real
address.
- **Producer egress is the `EgressFactory` seam** (ADR-004, the
@@ -55,7 +55,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
via the alktunnels direct op) are policy, not protocol.
- **The datagram stage is `select!`-driven** with the egress
readiness contract (next_reply is `Pending` when nothing is
ready — an instantly-ready `Ok(None)` starves a biased select into
ready; an instantly-ready `Ok(None)` starves a biased select into
livelock; POC #2's readiness findings are load-bearing API design
for the crate). Framing is applied exactly once, by the wire
layer: `Egress::next_reply` returns *inner* bytes; the producer's
@@ -64,7 +64,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
reader state persists.** The associate session's
`send_datagram` frames once; its `recv_datagram`'s incremental
reader state (mid-frame position + batched-datagram pending
queue) persists across calls — a datagram split across channel
queue) persists across calls; a datagram split across channel
chunks survives a caller-driven receive loop (POC #2: the first
cut dropped the reader state per call; the 2-byte-prefix chunk
test exposed it). This is part of the datagram stage's
@@ -76,22 +76,22 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
BIND need no socket at all; ASSOCIATE's "bind" is virtualized as
the channel (the handle swap). Local binds remain optional
assembly-layer shapes (front doors; the local egress variant).
- The datagram framing is **wire-stable once published** — the
- The datagram framing is **wire-stable once published**; the
one-way door is now closed with empirical ground under it (open
cost ~0.8 ms per composed destination, measured; LRU + dedup
pinned by test).
- Boundary preservation is mandatory (empty datagram ≠ EOF —
- Boundary preservation is mandatory (empty datagram ≠ EOF;
alktunnels F-2's lesson transfers directly; the frame shape meets
it structurally). The BAST document for the frame is
[bast.md](../bast.md).
## References
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound —
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound;
see ADR-010)
- POC #2 `poc-udp-associate-findings.md` (the validation record +
the readiness/framing/ownership findings)
- alktunnels ADR-003 (the length codec + F-2), alktunnels
ADR-007 (the direct op — the composed egress vehicle)
- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md` — the frame
ADR-007 (the direct op, the composed egress vehicle)
- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md`, the frame
design prior art)
@@ -1,9 +1,9 @@
# ADR-004: The Dial-Callback Shape — Function, not Trait, at Three Seams
# ADR-004: The Dial-Callback Shape: Function, not Trait, at Three Seams
## Status
Accepted (2026-09-20, Phase 1; shape resolved empirically — POC #1
(2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15))
Accepted (2026-09-20, Phase 1; shape resolved empirically, with
POC #1 (2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15))
## Context
@@ -14,23 +14,23 @@ inversion point for the three I/O planes a SOCKS5 server touches:
the CONNECT dial, the ASSOCIATE datagram egress, and the BIND
accept. alktunnels resolved the same question for its establisher
with the no-trait finding (OQ-TN-05 → alktunnels ADR-004): "produce
boxed halves for a resource" is a function, not a trait — two real
boxed halves for a resource" is a function, not a trait; two real
implementations existed as callbacks, and the trait added nothing.
The POCs extended that finding to all three SOCKS5 seams:
- **CONNECT**: the dial cannot live in the establisher — the target
- **CONNECT**: the dial cannot live in the establisher; the target
is unknown at open time (it arrives in-stream at command read).
The injected `DialFn(target) -> TargetHandle {read, write}`
closure is called at the command-read interception point
(`poc-connect-wasm-findings.md` §Structural findings). Per-target
refusal is an RFC reply code; session-level refusal stays
channels-level.
- **ASSOCIATE**: the `EgressFactory` — called once per association;
- **ASSOCIATE**: the `EgressFactory`, called once per association;
the datagram stage owns the egress; stage exit drops it
(`poc-udp-associate-findings.md` §The per-association egress
instance).
- **BIND**: the `BindAcceptFactory` — one instance per session; its
- **BIND**: the `BindAcceptFactory`, one instance per session; its
future owns the listener/tunnel and is dropped by the quiet
wait's `select!` on the control-stream branch (the client-gone
teardown; `poc-bind-findings.md` §The quiet wait is
@@ -40,20 +40,20 @@ The POCs extended that finding to all three SOCKS5 seams:
- **No dialer/egress/accept trait.** All three seams are
function-shaped callbacks injected at registration/assembly:
- `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync` —
satisfies the `ChannelPlan` payload bound; the bound binds
handle/plan payloads downstream — the SOCKS5 establisher
- `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync`,
satisfying the `ChannelPlan` payload bound; the bound binds
handle/plan payloads downstream; the SOCKS5 establisher
itself carries no plan, `Establishment::default()`)
- `EgressFactory() -> impl Egress` (per-association instance;
`next_reply` readiness contract: `Pending` when nothing is
ready)
- `BindAcceptFactory` (per-session; the future owns its resources
end-to-end — the ownership-rides-the-handle lesson)
end-to-end, the ownership-rides-the-handle lesson)
- **Ownership rides the handle structurally.** Plan/handle payloads
own their channel sessions and socket halves inside the payload
(the third witness of the teardown-at-birth pattern: POC #2's
egress table, alktunnels ADR-005, POC #5's `BindTargetHandle`).
The compiler should enforce it — no `_` bindings of owning
The compiler should enforce it: no `_` bindings of owning
values.
- **The trait re-evaluation threshold is the same as alktunnels
ADR-004's**: revisit if three real implementations exist as trait
@@ -69,7 +69,7 @@ The POCs extended that finding to all three SOCKS5 seams:
the module map in [overview.md](../overview.md)).
- The composed providers (alktunnels direct-op egress, bind-first
listen accept) are assembly-layer compositions, not crate
dependencies of the protocol layer — the protocol crate depends
dependencies of the protocol layer; the protocol crate depends
on alkcall only (see [overview.md](../overview.md) §Dependencies;
the composed-provider helpers that reference alktunnels types live
behind the composition feature, ADR-009).
@@ -79,7 +79,7 @@ The POCs extended that finding to all three SOCKS5 seams:
## References
- OQ-SK-01 (the dial policy — the A/B/C options; this ADR closes
- OQ-SK-01 (the dial policy, the A/B/C options; this ADR closes
the dialer-trait-vs-callback half), OQ-SK-03 (the egress seam),
OQ-SK-08 (the accept seam)
- POC #1/#2/#5 findings (the three seams' empirical records)
@@ -1,23 +1,23 @@
# ADR-005: Auth Mapping — "Both," Split by the Door
# ADR-005: Auth Mapping: "Both," Split by the Door
## Status
Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13 —
OQ-SK-02 — and POC #1 validated the identity seam end to end)
Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13
(OQ-SK-02), and POC #1 validated the identity seam end to end)
## Context
Two auth mechanisms are real: alkcall's identity seam (the per-call
opener identity arrives on the open-op hooks — alkcall ledger
opener identity arrives on the open-op hooks; alkcall ledger
CF-005/CF-006) and RFC 1929's in-band username/password. Phase 0
resolved the posture: both mechanisms are real, each on its own side
of the door — the consumer's optional front door (where the vanilla
of the door: the consumer's optional front door (where the vanilla
world meets the ACL world). A producer-side password would add
nothing the identity seam doesn't already do, and the ACL system is
strictly better than a shared secret.
POC #1's wiring finding (the accept-side install hook must call
`channel0_conn.set_identity(resolved)` — the transport identity does
`channel0_conn.set_identity(resolved)`; the transport identity does
not flow to channel-0 dispatch automatically) is a producer-harness
requirement worth surfacing in the spec, not a protocol-layer
concern.
@@ -27,12 +27,12 @@ concern.
- **Producer side: identity only.** The channels-native open is
authorized by the alkcall ACL/identity seam. The producer's
in-band RFC conversation runs NoAuth via the typestate's normal
path — `accept_no_auth` (the greeting and method selection still
path: `accept_no_auth` (the greeting and method selection still
happen; POC #2/#5's validated shape: `accept_no_auth →
read_command`). The negotiation is real but the auth decision is
vacuous in-band because authorization already happened at the
open gate. **`skip_auth_this_is_not_rfc_compliant` is excluded
from the in-band path** — it wraps a stream already marked
from the in-band path**; it wraps a stream already marked
authenticated, skipping the greeting entirely; feeding it a
client that speaks the RFC greeting would misparse the greeting
as command bytes. (At most it belongs to a raw-pipe door that
@@ -41,26 +41,26 @@ concern.
where vanilla clients need it.** The door owns credential
presentation: a vanilla client carrying `user:pass` gets a real
RFC 1929 conversation at the door. The door then maps the
accepted credential to *local* configuration — which channel
identity/endpoint to open the producer channel with — and that
accepted credential to *local* configuration (which channel
identity/endpoint to open the producer channel with), and that
identity reaches the producer via the CF-005/CF-006 seam. **The
SOCKS password never crosses the channel as a SOCKS password; the
channel identity never reaches the vanilla client.**
- **No fast-socks5 `AuthMethod` impl is required.** The door checks
credentials itself before/around handing the stream to the
typestate — the door is this crate's code; the trait was designed
typestate; the door is this crate's code; the trait was designed
for embedders who don't own the accept loop.
- **The door's credential→identity mapping shape: static config.**
The mapping is a caller-supplied configuration table
(`username/password → channel identity or endpoint selection`)
— static, declarative, assembly-layer. A pluggable lookup
(`username/password → channel identity or endpoint selection`),
static, declarative, assembly-layer. A pluggable lookup
(callback) remains possible as an additive extension if a
deployment needs dynamic resolution; v1 ships the static shape
because the front door's use cases (tun2proxy's fixed
`user:pass@host`, curl's `--proxy-user`) are single-credential or
small-table shapes.
- **The NoAuth door remains valid** for loopback-only or
wrapper-aware use (POC #1's raw-pipe shape — zero protocol
wrapper-aware use (POC #1's raw-pipe shape: zero protocol
translation); the cred-bearing door is the same bridge plus the
greeting half. Door variant selection is an assembly-layer
choice.
@@ -70,15 +70,15 @@ concern.
- Vanilla-client compatibility at the front door is real (RFC 1929),
without polluting the channels-native path with shared secrets.
- The producer-side optional local backend (a front door in the
other direction — vanilla clients → a locally-served SOCKS5
other direction: vanilla clients → a locally-served SOCKS5
resource) offers the same door surface: door = vanilla↔ACL
boundary, same code shape.
- The mapping is not wire-visible: no params, no reply fields, no
ALPN changes — the door is entirely assembly-layer.
ALPN changes; the door is entirely assembly-layer.
## References
- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved —
- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved;
the mechanisms occupy different planes)
- POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring
finding; the raw-pipe front door)
@@ -1,20 +1,20 @@
# ADR-006: Access-Control Posture — the Open Gate Plus the Command Gate
# ADR-006: Access-Control Posture: the Open Gate Plus the Command Gate
## Status
Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape —
Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape:
POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch);
consequence asymmetry pinned by the integration suite, 2026-09-24)
## Context
SOCKS5 is arbitrary-egress by nature — the open gate plus the target
SOCKS5 is arbitrary-egress by nature; the open gate plus the target
policy are the security boundary (AGENTS.md convention 12).
alktunnels resolved the sibling posture (its ADR-006): whatever ACL
governs the resource governs everything reachable through it, plus
whatever policy the SOCKS5 implementation itself applies downstream.
For SOCKS5, BIND adds an *inbound* capability — the accept-side twin
of arbitrary egress — so the open gate must distinguish it
For SOCKS5, BIND adds an *inbound* capability, the accept-side twin
of arbitrary egress, so the open gate must distinguish it
(OQ-SK-08's security note): an identity with proxy egress must not
implicitly get ingress listeners. POC #5 validated exactly this
distinction at command dispatch from the per-call opener identity.
@@ -23,10 +23,10 @@ distinction at command dispatch from the per-call opener identity.
- **The open gate is the boundary.** The open op's
`AccessControl.required_scopes` is `["socks5:open"]`
(`SOCKS5_OPEN_SCOPE`) — scope-gated via
(`SOCKS5_OPEN_SCOPE`), scope-gated via
`ChannelCore::register_openable` (the registry runs the ACL
before the open handler). Scope-string convention:
`<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`) —
`<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`),
following alktty's `TTY_OPEN_SCOPE` / alktunnels'
`tunnel:open`/`tunnel:direct` shape. Stable once published.
- **The command gate: BIND requires `socks5:bind` at command
@@ -38,7 +38,7 @@ distinction at command dispatch from the per-call opener identity.
`ConnectionNotAllowed` (0x02) refusal while CONNECT still serves
the same identity (POC #5 test 4).
- **Target policy is the dialer's concern.** No target allowlists
in the base crate — the dial callback refuses (per-target refusal
in the base crate; the dial callback refuses (per-target refusal
is an RFC reply code; ADR-004's seams). The dialer-refuses shape
avoids a second policy layer (alktunnels OQ-TN-08's resolution
pattern, evidence-backed). Whatever ACL governs the socks5
@@ -49,23 +49,23 @@ distinction at command dispatch from the per-call opener identity.
by the `tunnel:direct` grant's ACL (POC #2 validated); the dial
callback governs per-destination checks.
- **Ownership**: optionally consult `OwnershipProvider` for
resource ownership (alknet ADR-050's seam) — a registration-time
resource ownership (alknet ADR-050's seam), a registration-time
choice, not a protocol concern.
- **Hub relaying**: the hub's terminate-and-re-produce applies per
hop (alkcall ADR-051's `ChannelRelay`/`HubLegTemplate`); the hub's
ACL governs the final dial — the hub is the network boundary. No
ACL governs the final dial; the hub is the network boundary. No
socks5-specific relay work.
## Consequences
- Two grants, clean separation: `socks5:open` (egress) and
`socks5:bind` (ingress listeners) — never implied by each other
`socks5:bind` (ingress listeners), never implied by each other
(the alktunnels ADR-006 Amendment 1 posture: a capability on its
own scope). The implication asymmetry is pinned by the integration
suite (`tests/core-integration.rs`, the identity/scope matrix):
`socks5:open` alone serves CONNECT + ASSOCIATE and is refused BIND
(0x02) at the command gate; `socks5:bind` alone grants *nothing*
on the op — the open gate's `required_scopes = ["socks5:open"]`
on the op: the open gate's `required_scopes = ["socks5:open"]`
fails closed for every command (the establisher never runs, no
phantom channel); both scopes, granted explicitly, serve
everything. A BIND-capable deployment grants both.
@@ -73,7 +73,7 @@ distinction at command dispatch from the per-call opener identity.
same op (ADR-001): the command gate is the mechanism, and it is
POC-validated (POC #5).
- The allowlist-hook variant (a declarative per-target policy at
registration) is *not* shipped in v1 — the dial callback is the
registration) is *not* shipped in v1: the dial callback is the
policy point; see OQ-SK-05's residue (OQ-SK2-07 in the tracker)
for the virtual-subnet translation-layer proposal, which is the
same dial-callback shape.
+12 -12
View File
@@ -1,4 +1,4 @@
# ADR-007: BIND in the Base Crate — the Phase Model Extended
# ADR-007: BIND in the Base Crate: the Phase Model Extended
## Status
@@ -15,7 +15,7 @@ asks for a listener on the producer's network; the producer replies
replies #2 with the peer address before the data plane. The use case
that makes it real (FTP active mode is the canonical vanilla reason)
plus the composed variant's hub story were found in Phase 0
(OQ-SK-08), and POC #5 validated the whole shape end to end — both
(OQ-SK-08), and POC #5 validated the whole shape end to end: both
accept variants, the scope gate, the failure mapping, the vanilla
front door.
@@ -28,8 +28,8 @@ Nothing here is a one-way door.
- **BIND ships in the base crate** (protocol layer), phase-model
style: request → reply#1 carrying `BND.ADDR` → quiet wait (any
control byte after reply#1 is protocol garbage; the session ends —
exactly two exits: the accept, or the control stream's EOF/
control byte after reply#1 is protocol garbage; the session ends,
with exactly two exits: the accept, or the control stream's EOF/
garbage/error) → exactly one inbound accept → reply#2 carrying the
peer address → raw pass-through data plane (zero per-packet
headers in-band, same as CONNECT; only the ASSOCIATE datagram
@@ -37,27 +37,27 @@ Nothing here is a one-way door.
- **The accept seam is `BindAcceptFactory`** (ADR-004). Two
provider-backed accept variants (plus the wrapper-aware in-band
default when neither is wired), all POC #5-validated:
1. **Local backend binds for real** (feature-gated) — the faithful
1. **Local backend binds for real** (feature-gated): the faithful
RFC shape: a real network-B address in reply#1 (the listener's
`local_addr`), a real peer in reply#2, vanilla front-door
clients work. **This is the default accept variant** for the
optional local backend.
2. **Composed (alktunnels listen resource)** — the listener lives
2. **Composed (alktunnels listen resource)**: the listener lives
on a bind-first listen resource (`register_tunnel_bindfirst_
openable` + `BindFirstInjections`, alktunnels 0.2.0); per-
listener ACL for free, hub-relayable. **Vanilla-fidelity with
no stand-ins** since the graduation: the factory no longer
carries an expected `bind_addr` — reply#1 advertises the real
OS-chosen `bound` (the open-op reply's `bound` field,
no stand-ins** since the graduation: the factory no longer
carries an expected `bind_addr`; reply#1 advertises the real
OS-chosen `bound` (the open-op reply's `bound` field,
alkcall 0.8.0 reply projection); reply#2 carries the real peer
(the forwarded op's `peer` param).
3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2) —
3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2),
retained for completeness as the in-band default when neither
provider is wired: the handle-swap reading applied to BIND
(the reply is RFC-compatibility surface; the data plane is the
channel).
- **The establishment bound covers only the open-op round trip.**
The accept wait lives in the teardown-governed pump phase — FTP-
The accept wait lives in the teardown-governed pump phase; FTP-
length BIND waits stop resolving as spurious establishment
refusals (the bind-first graduation's stated second win;
`poc-bind-findings.md` §Establishment-bound, superseded in shape).
@@ -71,7 +71,7 @@ Nothing here is a one-way door.
- The RFC 1928 command surface is complete: CONNECT + BIND +
ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded
the other two).
- The composed accept path composes against alktunnels 0.2.0 — the
- The composed accept path composes against alktunnels 0.2.0: the
bind-first establisher + forwarded op (ADR-008) are the substrate;
the POC's pinned workaround (reply#1 first, the tunnel open rides
the accept future) is retired with the graduation.
@@ -1,9 +1,9 @@
# ADR-008: Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op)
# ADR-008: Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op)
## Status
Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice —
OQ-SK-01's residue — with the composed vehicle settled by
Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice
(OQ-SK-01's residue), with the composed vehicle settled by
alktunnels 0.2.0 ADR-007)
## Context
@@ -11,12 +11,12 @@ alktunnels 0.2.0 ADR-007)
OQ-SK-01's dialer-trait-vs-callback half is closed (ADR-004); what
remained was the **default provider set**: where does the dial
callback come from in the base crate's optional shapes? Option A
(dial via alktunnels — the composed shape), Option B (dial directly
— local TCP), Option C (injected dialer trait — rejected, ADR-004).
(dial via alktunnels, the composed shape), Option B (dial directly,
local TCP), Option C (injected dialer trait, rejected, ADR-004).
alktunnels 0.2.0 removed Option A's blocker (the direct op
`channels/tunnel/direct` is the dynamic-target channel — one open
`channels/tunnel/direct` is the dynamic-target channel: one open
per CONNECT target, what it was graduated for), and POC #2 measured
its per-destination open cost (~0.8 ms, loopback) — fine for DNS +
its per-destination open cost (~0.8 ms, loopback), fine for DNS +
browsing.
## Decision
@@ -25,7 +25,7 @@ browsing.
- **Local-socket providers** (behind the `local` feature): a real
`TcpStream` dial (CONNECT), a real `UdpSocket` egress
(ASSOCIATE, one socket per association, per-datagram
`send_to`/`recv_from` — no flow table; domain destinations
`send_to`/`recv_from`; no flow table; domain destinations
resolve at the egress, the ssh `-D` semantic), a real
`TcpListener` accept (BIND, ADR-007 variant 1). These are the
faithful-RFC shapes and the simplest assembly story.
@@ -33,10 +33,10 @@ browsing.
dial callback opens an alktunnels direct-op channel naming the
target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the
UDP table is a lazy destination→tunnel table (open on first
datagram, LRU-evict at a cap — POC #2's `ComposedEgress` shape).
datagram, LRU-evict at a cap, POC #2's `ComposedEgress` shape).
Hub relaying is per-target terminate-and-re-produce
(alkcall ADR-051).
- **Neither provider is a dependency of the protocol layer** —
- **Neither provider is a dependency of the protocol layer**;
both are assembly-layer wiring (ADR-004); the features exist so
the providers ship *in the crate* rather than forcing every
deployment to hand-roll them. The feature gates and module
@@ -46,11 +46,11 @@ browsing.
The feature-gated providers are convenience constructors over
the same seams. A missing callback means that command refuses
RFC-faithfully (`CommandNotSupported`-class reply), never a
panic — the command gate is the open handler's dispatch, and a
panic: the command gate is the open handler's dispatch, and a
provider-less command is an unprovided capability, not an error
condition.
- **Per-target ACL is the composed variant's benefit** (each
destination rides its own `tunnel:direct`-governed channel —
destination rides its own `tunnel:direct`-governed channel,
ADR-006); the local variant's target policy is the dial
callback itself.
@@ -1,11 +1,11 @@
# ADR-009: Feature-Gate Layout — `local` and the Composition Boundary
# ADR-009: Feature-Gate Layout: `local` and the Composition Boundary
## Status
Accepted (2026-09-20, Phase 1; the wasm-clean default-crate invariant
is the alk* family posture — AGENTS.md convention 4). Amended
is the alk* family posture, AGENTS.md convention 4). Amended
2026-09-21 (pre-decomposition): the vendor's `net` gate dissolves
under ADR-011's extraction amendment — the vendor is
under ADR-011's extraction amendment; the vendor is
wasm-clean by construction; features collapse to `local` +
`composed`; see §Amendment.
@@ -13,20 +13,20 @@ wasm-clean by construction; features collapse to `local` +
The default crate must compile to `wasm32-unknown-unknown` (the
alktty/alktunnels precedent; the wasm shape is a sandboxed
SOCKS5 consumer — pure byte framing, no local sockets needed
SOCKS5 consumer (pure byte framing, no local sockets needed
client-side; phase-0 OQ-SK-04's analysis). Two groups of
platform-specific or sibling-dependent code need gating:
1. **The crate's own local backends** (dial/listen/egress providers
touching real sockets — ADR-008's local variants).
touching real sockets, ADR-008's local variants).
2. **The composed providers** (the alktunnels-composed egress/accept
helpers — ADR-008's composed variants), which need the
helpers, ADR-008's composed variants), which need the
alktunnels dependency.
The vendor originally contributed a third gate: its kernel-net
region was to ride a default-on `net` feature (POC #4's measured
shape, ~120 cfg lines across 5 files). ADR-011's 2026-09-21
amendment pins the vendor as *extraction* — the kernel-net region is
amendment pins the vendor as *extraction*; the kernel-net region is
not copied, so that gate no longer exists (see §Amendment). POC #4's
gate map survives as the extraction recipe, not as in-tree cfg.
@@ -38,26 +38,26 @@ gate map survives as the extraction recipe, not as in-tree cfg.
feature-gated, never imported by the protocol layer.
- **The default build has no `net` feature**: the extracted vendor
contains zero kernel-net code, so the wasm-clean subset is the
whole crate — there is nothing to gate (ADR-011 §Amendment).
- **`local` (default-off)**: the crate's first-party local backends
— the TCP dial, the UDP egress socket, the BIND listener, and the
front doors (the consumer's ssh `-D` local exposure; the
whole crate; there is nothing to gate (ADR-011 §Amendment).
- **`local` (default-off)**: the crate's first-party local backends,
namely the TCP dial, the UDP egress socket, the BIND listener, and
the front doors (the consumer's ssh `-D` local exposure; the
producer's local backend). Brings `tokio/net` + `socket2` into the
graph. Non-wasm by design (enabling it on wasm is a build error,
the alktty posture).
- **`composed` (default-off)**: the composed providers over
alktunnels 0.2.0 (direct-op egress, bind-first listen accept) —
alktunnels 0.2.0 (direct-op egress, bind-first listen accept),
the only feature that adds the alktunnels dependency. The
protocol layer never references alktunnels types; the composed
module does, injected at the assembly layer (ADR-004/ADR-008).
- **tokio stays `default-features = false`** with the wasm-clean
subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never
subset (`rt`, `sync`, `io-util`, `macros`, `time`); **never
`features = ["full"]`** (AGENTS.md convention 4); `net`-shaped
additions ride `local` alone.
## Amendment (2026-09-21, pre-decomposition — the vendor's `net` gate dissolves)
## Amendment (2026-09-21, pre-decomposition: the vendor's `net` gate dissolves)
The original decision carried a third feature — `net`
The original decision carried a third feature, `net`
(default-on), the vendored subset's kernel-net gate, with `local`
implying it. ADR-011's extraction amendment dissolves it:
@@ -66,12 +66,12 @@ implying it. ADR-011's extraction amendment dissolves it:
`Socks5Server`/`Incoming` API, `Socks5Datagram`'s kernel path, the
DNS-resolve helper, the net-only tests), the `socks4` module, and
the legacy `Config`/`Authentication` layer are not copied into the
tree — the extraction is wasm-clean by construction, with no
tree; the extraction is wasm-clean by construction, with no
in-tree cfg gate.
- Features collapse to two: **`local`** (first-party socket
providers + front doors, ADR-008; brings `tokio/net` + `socket2`
into the graph itself) and **`composed`** (the alktunnels
providers). `local` no longer implies a separate `net` feature —
providers). `local` no longer implies a separate `net` feature;
it is the sole gate for socket/platform I/O.
- POC #4's gate map survives as the *extraction recipe* (its
ungated inventory is what to copy), not as in-tree cfg.
@@ -81,24 +81,24 @@ implying it. ADR-011's extraction amendment dissolves it:
## Consequences
- Wasm consumers (the sandboxed adapter story — a product input,
- Wasm consumers (the sandboxed adapter story; a product input,
nothing waits on it) build the default crate clean; the protocol
substance *is* the wasm-relevant surface.
- The verification commands cover the gates: `cargo test` (default),
`cargo test --all-features`, and both wasm checks
(`cargo check/clippy --target wasm32-unknown-unknown`) — the
(`cargo check/clippy --target wasm32-unknown-unknown`), the
structural guard for the invariant.
- The vendor's carried changes (BIND, `new_reply` publication) live
in the extracted region where they touch codecs/typestate; nothing
vendored is gated — the only feature-gated socket code is the
vendored is gated; the only feature-gated socket code is the
crate's own first-party `local` module.
## References
- OQ-SK-04 (resolved: the extracted-and-forked base — ADR-011/
- OQ-SK-04 (resolved: the extracted-and-forked base; ADR-011/
ADR-013), OQ-SK-01/03/08
(the providers the features gate)
- POC #4 `poc-connect-wasm-findings.md` (the measured gate — now the
- POC #4 `poc-connect-wasm-findings.md` (the measured gate, now the
extraction recipe),
alktty's `local` feature (the posture precedent), alktunnels
ADR-004 + overview.md (the same feature split)
@@ -1,4 +1,4 @@
# ADR-010: Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change
# ADR-010: Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change
## Status
@@ -14,12 +14,12 @@ CONNECT channel per flow; composed UDP egress spends a downstream
channel per destination; BIND spends an accept resource per
listener). A proxy-shaped deployment will discover the cap in
production unless the crate documents the posture. The caps are
policy knobs — configurable without a wire change (the `u32`
channel-ID wire space is ~4 billion) — so the resolution is config
policy knobs, configurable without a wire change (the `u32`
channel-ID wire space is ~4 billion), so the resolution is config
plus documentation, never wire.
Considered and rejected: in-channel port multiplexing (a
`[port: u16][len][payload]` per-chunk prefix) — it re-opens the
`[port: u16][len][payload]` per-chunk prefix); it re-opens the
demux-vs-phase trade ADR-003 (the handle swap) resolved, breaks
the 0-B pass-through that lets the forked `transfer` drop in
unchanged, imposes chunk boundaries on a stream RFC 1928 treats as
@@ -28,14 +28,14 @@ convention (AGENTS.md #10).
## Decision
- **No wire change — rejected permanently.** The caps are policy
- **No wire change (rejected permanently).** The caps are policy
knobs (alkcall ADR-040/041); the resolution is config.
- **Assembly-layer config is the mechanism**: a proxy-shaped
deployment raises the per-connection/per-identity caps at
registration; the default stays conservative for everyone else.
- **Documented proxy-workload recommendation**: the crate docs
recommend a raised cap for proxy deployments, with the memory
arithmetic — a session's memory is bounded + predictable (CONNECT/
arithmetic: a session's memory is bounded + predictable (CONNECT/
ASSOCIATE are bounded-buffer pumps; BIND adds one listener + one
accept), so the per-connection cap can rise safely relative to a
general-purpose deployment. A concrete recommended figure is a
@@ -44,12 +44,12 @@ convention (AGENTS.md #10).
cap → per-identity cap → dial/accept policy. Relaxing the caps
leans on the rest. The per-identity DoS brake is arguably
*stronger* here (each SOCKS5 session maps to real producer-side
resources — a dial, a downstream channel, an accept listener);
resources (a dial, a downstream channel, an accept listener);
the per-connection memory bound is arguably *weaker* (the three
command shapes are well-known and bounded).
- **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed
upstream channel budget that config cannot raise, the additive
escape is a *new multiplexed ALPN* — never a change to this
escape is a *new multiplexed ALPN*, never a change to this
ALPN's framing.
## Consequences
@@ -57,7 +57,7 @@ convention (AGENTS.md #10).
- Deployments get the guidance before they hit the cap; no protocol
surface changes.
- The rejected multiplexing proposal has a recorded rationale and a
revisit trigger — the escape hatch is additive (new ALPN), never
revisit trigger; the escape hatch is additive (new ALPN), never
a framing change.
## References
@@ -1,12 +1,12 @@
# ADR-011: the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree
# ADR-011: the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree
## Status
Superseded by [ADR-013](013-targeted-fork.md) (2026-09-22): the
maintenance posture (the vendor framing — re-base/re-vendor
maintenance posture (the vendor framing, re-base/re-vendor
discipline, the verbatim/lint-allow apparatus) is retired; the
extracted region is a targeted fork, module `rfc1928`. **The
extraction mechanics here remain authoritative** — the cut table,
extraction mechanics here remain authoritative**; the cut table,
the two carried changes (BIND, `new_reply`), the §Amendment
(extraction, not wholesale copy; the net gate dissolves) describe
what was copied and what was not; only the post-copy maintenance
@@ -16,57 +16,57 @@ OQ-SK-04's resolution), Amended 2026-09-21 (pre-decomposition).
## Context
fast-socks5 is **third-party upstream** (v1.0.0, MIT;
`/workspace/fast-socks5` is a read-only reference checkout — we do
`/workspace/fast-socks5` is a read-only reference checkout; we do
not own it, and it has not been forked). The wrapper's needs go
beyond what it publishes:
1. **The wasm gate** — `tokio::net` + `socket2` (via `mio`) hard-error
1. **The wasm gate**: `tokio::net` + `socket2` (via `mio`) hard-error
on `wasm32-unknown-unknown`; the default crate must compile there
(AGENTS.md convention 4). POC #4 measured the gate: ~120 changed
lines across 5 files, all `#[cfg]` insertions; the wasm-clean
subset is functionally complete (a full RFC 1928 CONNECT
conversation passes `--no-default-features`).
2. **BIND is absent** — `Socks5Command::TCPBind` is parsed but every
2. **BIND is absent**: `Socks5Command::TCPBind` is parsed but every
handler refuses it; POC #5 proved the wrapper must drive the
typestate itself for the BIND shape (ADR-007).
3. **The `run_udp_proxy_custom` bind seam** — the "custom" seam is
3. **The `run_udp_proxy_custom` bind seam**: the "custom" seam is
narrower than it looks: it calls `udp_bind_random_port`
unconditionally and derives the reply port from that kernel
socket; the channels path cannot use it (verified 2026-09-13).
The channels path drives the typestate directly; making the bind
optional / accepting a caller-supplied socket is an in-tree fix.
4. **`new_reply` is private** — the reply encoder is private; the
4. **`new_reply` is private**: the reply encoder is private; the
BIND reply#2 writer otherwise hand-rolls the 10-byte reply
(POC #5's courtesy item, now a vendor change).
Two facts settled the posture: the ownership premise was wrong
(no cheap "file asks / PR" path), and the carried needs (2–4) exist
regardless of the wasm question — so the vendor pays for itself
regardless of the wasm question, so the vendor pays for itself
without leaning on the adapter story. The change set is exactly the
shape POC #4 measured as minimal. (Need 1 later dissolves —
shape POC #4 measured as minimal. (Need 1 later dissolves,
§Amendment; needs 3's handler is not extracted with it.)
## Decision
- **Extract the `T`-generic subset into this crate** (see §Amendment
for the mechanical reading of "extract"): the codecs, the typestate
machinery, the client `use_stream` path — the parts already generic
machinery, the client `use_stream` path, the parts already generic
over `T: AsyncRead + AsyncWrite + Unpin`. The extraction contains
zero kernel-net code and is wasm-clean by construction.
- **Carry the four needs in the same vendor**: the net gate, BIND
(the typestate-driven shape, ADR-007), the `run_udp_proxy_custom`
seam fix, and the `new_reply` publication. (Post-Amendment: the
net gate dissolves — see §Amendment; BIND and `new_reply` are the
net gate dissolves; see §Amendment; BIND and `new_reply` are the
two that touch extracted code.)
- **Note the vendor point (v1.0.0) in the vendored code**; keep the
MIT license obligations (upstream is MIT; preserve the license
header per file or a bundled notice).
- **`/workspace/fast-socks5` stays the differential-testing
reference** for RFC edge cases (reply-code mapping, domain
addressing, fragmentation) — keep `/workspace/fast-socks5` for
addressing, fragmentation); keep `/workspace/fast-socks5` for
differential testing; never wait on an upstream merge.
- **Offering the net-gate upstream as a courtesy PR stays optional**
(POC #4: it is small enough to offer as-is) — never a wait, never
(POC #4: it is small enough to offer as-is), never a wait, never
a dependency.
## Consequences
@@ -75,7 +75,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
cadence, no feature waits, and the vendored code co-evolves with
the wrapper (the carried changes are first-class, not patches).
- The cost is maintenance of the extracted subset (the `T`-generic
region + codecs — a fraction of upstream's ~4k LoC; POC #4's
region + codecs, a fraction of upstream's ~4k LoC; POC #4's
ungated inventory is the shopping list). The diff vs upstream is
deliberate and bounded: the extraction cut + the carried changes +
the license/provenance notes.
@@ -86,7 +86,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
- Differential testing against `/workspace/fast-socks5` guards the
RFC edge cases the extracted code must keep passing.
## Amendment (2026-09-21, pre-decomposition — "extract" pinned, the `net` gate dissolves)
## Amendment (2026-09-21, pre-decomposition: "extract" pinned, the `net` gate dissolves)
The decision's wording ("vendor the `T`-generic subset") supported
two mechanical readings; this amendment pins the intent so the
@@ -109,12 +109,12 @@ decomposition task is unambiguous:
- **Why**: convention 17's letter ("vendor the `T`-generic subset");
no crate consumer routes through the kernel-net region (the
producer/consumer/doors drive the typestate and the pure codecs;
the local backends are first-party tokio code — ADR-004/ADR-008);
the local backends are first-party tokio code, ADR-004/ADR-008);
and the BIND + `new_reply` needs touch only the extracted region
(BIND's driving is wrapper-side code per POC #5 — "zero upstream
(BIND's driving is wrapper-side code per POC #5, "zero upstream
ask for BIND itself").
- **The `net` feature gate dissolves.** With no kernel-net code
in-tree, the extraction is wasm-clean by construction — no
in-tree, the extraction is wasm-clean by construction; no
`#[cfg]` gate needed inside the vendored files. Features collapse
to two: `local` (the crate's first-party socket providers and
front doors, ADR-008) and `composed` (the alktunnels providers).
@@ -123,11 +123,11 @@ decomposition task is unambiguous:
stays `default-features = false` with the wasm-clean subset
(AGENTS.md convention 4); `tokio/net` rides `local` alone.
- **The carried-changes list re-reads under extraction**: (1) the
net gate — dissolves (no net code); (2) BIND — stays (the typestate
net gate, dissolves (no net code); (2) BIND, stays (the typestate
driving + reply#2 writer are in-tree; ADR-007); (3) the
`run_udp_proxy_custom` bind seam — dissolves (the handler isn't
`run_udp_proxy_custom` bind seam, dissolves (the handler isn't
extracted; the typestate-driving shape POC #2 validated is the
crate's ASSOCIATE path); (4) `new_reply` publication — stays
crate's ASSOCIATE path); (4) `new_reply` publication, stays
(it is inside the extracted codec region). Net: **two carried
changes** touch extracted code.
- **Extraction completeness is compiler-checked**: a missed
@@ -139,10 +139,10 @@ decomposition task is unambiguous:
upstream's `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing` (the alk* convention); the
extracted region's `anyhow::Context` error wrapping is dropped
(error surface is the crate's `thiserror` types — the vendored
(error surface is the crate's `thiserror` types; the vendored
`SocksError`/`SocksServerError` stay the wire-level vocabulary);
`async-trait` is dropped (its four upstream uses are all on the
legacy `Authentication` layer, which is cut — the typestate's
legacy `Authentication` layer, which is cut; the typestate's
`AuthMethod<T>` traits are native async-fn-in-trait); the
`socks4` module and the legacy `Config`/`Authentication` layer are
out (above). These are extraction-time mechanical adjustments to
@@ -157,8 +157,8 @@ wasm check a hard gate as in alktty/alktunnels.
- OQ-SK-04 (the resolved posture; the reasoning trail),
OQ-SK-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam)
- POC #4 + POC #1 (`poc-connect-wasm-findings.md` — the measured
gate), POC #5 (`poc-bind-findings.md` — the BIND typestate
- POC #4 + POC #1 (`poc-connect-wasm-findings.md`: the measured
gate), POC #5 (`poc-bind-findings.md`: the BIND typestate
driving + the `new_reply` finding)
- ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature
gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0)
@@ -3,7 +3,7 @@
## Status
Accepted (2026-09-20, Phase 1; the dialer-refuses shape is
OQ-SK-05's evidence-backed resolution — see ADR-006 — and the
OQ-SK-05's evidence-backed resolution (see ADR-006), and the
2026-09-14 virtual-subnet proposal is resolved as assembly-layer
policy)
@@ -11,15 +11,15 @@ policy)
OQ-SK-05's residual Phase 1 questions: (a) does the base crate ship
a per-target allowlist hook, or is target policy entirely the
dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03) —
dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03):
resolve producer-side, refuse, or pass through unresolved; (c) the
2026-09-14 virtual-subnet proposal (identity-scoped subnets so
proxied resources become IP-addressable — "BND.ADDR ↔ channel id"
proxied resources become IP-addressable ("BND.ADDR ↔ channel id"
translation, a hub exposing proxied resources per identity under
ACL).
The proposal's key property: the mechanism needs **no wire change**
— DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual
The proposal's key property: the mechanism needs **no wire change**;
DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual
addresses ride ordinary CONNECT/BIND requests; the translation layer
is the dial callback (`dial(target)` matches a virtual subnet
against a per-identity resource table).
@@ -34,13 +34,13 @@ against a per-identity resource table).
dialer-refuses.
- **Domain-form targets: pass through unresolved to the dialer.**
Resolution is the egress's/dialer's concern (POC #2 resolved the
local-egress half: "the egress owns resolution" — the ssh `-D`
local-egress half: "the egress owns resolution" (the ssh `-D`
semantic). The protocol layer never resolves: a wasm producer may
have no resolver at all, and the composed variant's targets ride
the alktunnels direct op (producer-side resolution there, per
alktunnels ADR-007's own posture). fast-socks5's `dns_resolve`
config (and its `resolve_dns` step) is not part of the extracted
vendor region at all — the crate's producer never resolves
vendor region at all; the crate's producer never resolves
(ADR-006/ADR-012), so the extraction drops both.
- **The virtual-subnet translation layer is assembly-layer policy,
not a crate feature.** The crate ships no translation-table
@@ -48,12 +48,12 @@ against a per-identity resource table).
wanting identity-scoped virtual subnets implements the
subnet→resource table there (per-identity scoping = resources +
ACL + target policy; hub exposure = the terminate-and-re-produce
story — no new wire vocabulary). The full-tunnel composition (the
story; no new wire vocabulary). The full-tunnel composition (the
host routes the subnet into the proxy via tun2proxy) is where a
virtual subnet becomes routable from vanilla apps:
`connect(10.x.y.z:p)` is an ordinary CONNECT the dialer
translates. tun2proxy's virtual-network/virtual-DNS support is
the prior art to check *if and when* a deployment builds this —
the prior art to check *if and when* a deployment builds this;
the crate's obligation is only that the seams support it, which
ADR-004's shape already does.
@@ -66,7 +66,7 @@ against a per-identity resource table).
translation layer rides the dial callback; if a deployment
materializes with concrete needs, a translation-table helper can
be added additively (assembly-layer code, possibly later
graduated into the crate behind a feature) — the seams don't
graduated into the crate behind a feature); the seams don't
change.
- DNS posture is uniform: producer-side resolution everywhere
(local egress resolves; composed egress hands the name to the
@@ -77,11 +77,11 @@ against a per-identity resource table).
- OQ-SK-05 (the full question set + the proposal's record),
OQ-SK2-07 (the deferred helper question this ADR's resolution
feeds), OQ-SK-01
(the dial callback), OQ-SK-09 (the multiplexing rejection — the
(the dial callback), OQ-SK-09 (the multiplexing rejection; the
virtual-subnet idea that survives is recorded in the tracker)
- POC #2 (`poc-udp-associate-findings.md` — the egress-owns-DNS
- POC #2 (`poc-udp-associate-findings.md`: the egress-owns-DNS
resolution), ADR-004 (the seams), ADR-006 (the ACL posture),
ADR-008 (the provider defaults)
- alktunnels phase-0-findings OQ-TN-08 (the target-policy
dissolution precedent); tun2proxy (`/workspace/tun2proxy` — the
dissolution precedent); tun2proxy (`/workspace/tun2proxy`, the
virtual-network prior art + the endgame composition partner)
@@ -1,17 +1,17 @@
# ADR-013: The Targeted Fork — fast-socks5's `T`-Generic Subset Is Owned, Not Vendored
# ADR-013: The Targeted Fork: fast-socks5's `T`-Generic Subset Is Owned, Not Vendored
## Status
Accepted (2026-09-22, post-extraction-review; supersedes the ADR-011
maintenance posture — ADR-011 stays for the extraction mechanics and
maintenance posture; ADR-011 stays for the extraction mechanics and
the cut table; the ADR-011 §Amendment is subsumed here)
## Context
ADR-011 pinned the inline-vendor posture as **extraction** (the
`T`-generic subset copied in-tree, kernel-net region not copied; the
net gate dissolving), and the vendor group — five implementation
tasks plus the review gate — landed it in commits `57b107b..5b18d4d`,
net gate dissolving), and the vendor group (five implementation
tasks plus the review gate) landed it in commits `57b107b..5b18d4d`,
with the review (`tasks/implementation/
review-vendor-extraction.md`) as the fidelity gate: the file-by-file
diff vs `/workspace/fast-socks5` v1.0.0 categorizes every hunk, and
@@ -20,23 +20,23 @@ every review criterion passed.
The review also made the maintenance posture's cost visible. The
tree is now permanently diverged from upstream in five categories:
1. **The extraction cut** — the kernel-net region, `socks4`, the
1. **The extraction cut**: the kernel-net region, `socks4`, the
legacy `Config`/`Authentication` layer, `Socks5Datagram`, the
convenience dialers, the DNS helper: gone.
2. **The carried changes** — BIND's reply surface, the published
2. **The carried changes**: BIND's reply surface, the published
`new_reply`.
3. **The panic translations** — `ReplyError::from_u8` (the review's
3. **The panic translations**: `ReplyError::from_u8` (the review's
F-1), `use_password_auth`, `reply_error`'s parse.
4. **The error-pruning adjustments** — anyhow/log/async-trait out,
4. **The error-pruning adjustments**: anyhow/log/async-trait out,
error variants pruned, `MissingTargetAddr` added.
5. **The lint-allow posture** — module-level `#[allow]`s preserving
5. **The lint-allow posture**: module-level `#[allow]`s preserving
upstream style (`u8::max_value()`, needless lifetimes,
`match_like_matches_macro`, `io_other_error`, `wrong_self_
convention`, the F-4 verbatim string) to keep the diff mechanical.
Category 5 exists solely to keep categories 1–4 reconcilable by a
line-based diff. But a future re-base against an upstream release
was never going to be a clean mechanical diff again — it was always
was never going to be a clean mechanical diff again; it was always
going to be manual reconciliation across all five categories. The
vendor posture's maintenance apparatus (verbatim preservation, the
lint allows, the re-vendor/adjustment-list discipline) pays a
@@ -44,7 +44,7 @@ permanent tax to serve a re-base path that would never be used.
The "vendor" framing also produced a real defect: the review's
F-1 (`ReplyError::from_u8`'s upstream `unreachable!()` arm kept
verbatim — wire-reachable, a misbehaving peer could panic the
verbatim; wire-reachable, a misbehaving peer could panic the
consumer task) escaped precisely because the vendor posture
maintained a second, weaker instruction set ("verbatim + adjustments
list" instead of "project conventions, unconditionally"). The
@@ -52,7 +52,7 @@ fidelity-vs-conventions tension is structural to the vendor framing;
the review caught the escape, but the seam itself was the vendor
framing's.
The fork point is now — post-review, post-F-1 — the cleanest line
The fork point is now, post-review and post-F-1, the cleanest line
the tree will ever present: the extraction provenance is in git
history (`57b107b..5b18d4d`), the review is the recorded fidelity
gate, and the five divergence categories are documented in ADR-011,
@@ -76,8 +76,8 @@ vendor.md, and the review task. "The tree as of the review commit
(now gone); "socks5" would stutter with the crate name
(`alksocks::socks5`) and collide with the future module structure
(the producer/consumer/adapter layers are also SOCKS5-shaped).
`rfc1928` names what the module *is* — the RFC 1928 wire
vocabulary and the state machines — permanent and collision-free.
`rfc1928` names what the module *is* (the RFC 1928 wire
vocabulary and the state machines), permanent and collision-free.
- **The lint-allow posture dissolves.** The module-level `#[allow]`s
and the "verbatim lint posture" module-doc paragraphs are removed;
the suppressed smells are fixed per project conventions:
@@ -100,7 +100,7 @@ vendor.md, and the review task. "The tree as of the review commit
pins bytes, not upstream text, so it survives the rename
untouched. Textual-diff discipline is retired; behavioral
differential testing is retained.
- **The cut table is re-read as the not-forked inventory** — the
- **The cut table is re-read as the not-forked inventory**, the
map of what to hand-port if a future need surfaces (e.g. the
kernel-net region for a standalone vanilla-server example). The
hand-port path replaces the re-vendor path: port against the
@@ -109,7 +109,7 @@ vendor.md, and the review task. "The tree as of the review commit
- **The honest cost**: the cheap upstream-fix path closes. A future
fast-socks5 fix (a reply-code mapping correction, a codec fix)
arrives as a hand-port against the reference checkout, not a
diff absorb. Risk is assessed low — fast-socks5 is a quiet
diff absorb. Risk is assessed low; fast-socks5 is a quiet
project, and the extracted surface is the well-trodden RFC
1928 path.
@@ -118,13 +118,13 @@ vendor.md, and the review task. "The tree as of the review commit
- **Positive**: one instruction set (project conventions,
unconditionally); the lint-allow tax and the adjustment-list
apparatus dissolve; smells are fixed at the only cost that
matters — a one-time cleanup verified by the existing suite.
matters: a one-time cleanup verified by the existing suite.
The F-1 seam class (conventions-vs-fidelity gaps) is closed
structurally.
- **Positive**: the module name is stable for the crate's future
(no `alksocks::socks5` stutter, no `socks5::producer` collision
pressure).
- **Negative (accepted)**: the cheap upstream-fix path closes — a
- **Negative (accepted)**: the cheap upstream-fix path closes; a
future upstream fix is a hand-port against the reference
checkout, guarded by the differential suite. Assessed low-risk
(quiet upstream, well-trodden RFC surface).
@@ -139,10 +139,10 @@ vendor.md, and the review task. "The tree as of the review commit
## References
- ADR-011 (the extraction mechanics, the cut table, the carried
changes — the mechanics remain authoritative), ADR-002 (the
changes; the mechanics remain authoritative), ADR-002 (the
protocol base), ADR-009 (feature gates, no `net` feature)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate + F-1), `docs/architecture/rfc1928.md` (the owned-core
spec)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral
- `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral
reference for differential testing)
@@ -1,10 +1,10 @@
# ADR-014: The Idle-Timeout Posture — Teardown-Governed Phases, No Protocol-Layer Idle Bound
# 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
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/
`composed` doors exist, whose tasks may depend on it, per ADR-008/
ADR-009's provider posture)
## Context
@@ -13,33 +13,33 @@ 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
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,
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
(ADR-007: teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the
channel" — implicit, never decided.
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 —
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
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
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.
@@ -51,61 +51,61 @@ Two facts sharpen the choice beyond the review's sketch:
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
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
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 —
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
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
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
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
**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 —
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.
(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
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
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
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
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
@@ -115,10 +115,10 @@ the same for a channel peer and for a kernel socket a door relays.
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);
(limits are inherited, not redefined, per 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
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
@@ -129,33 +129,33 @@ the same for a channel peer and for a kernel socket a door relays.
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
- **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 —
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
(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
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
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
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-
@@ -167,7 +167,7 @@ the same for a channel peer and for a kernel socket a door relays.
- 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
- [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)
@@ -179,7 +179,7 @@ the same for a channel peer and for a kernel socket a door relays.
- [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
- POC findings (the stage's measured shapes; the "sufficient for
relay postures" lineage)
- `/workspace/fast-socks5` (`src/util/stream.rs` — the dial-timeout
- `/workspace/fast-socks5` (`src/util/stream.rs`: the dial-timeout
mapping precedent, 0x06)
+40 -40
View File
@@ -1,46 +1,46 @@
---
status: draft
last_updated: 2026-09-28 (OQ-SK2-09 recorded — the UDP door's
last_updated: 2026-09-28 (OQ-SK2-09 recorded: the UDP door's
peer-pin posture; option 2 landed in 0.1.0, option 3 deferred;
OQ-SK2-06's trigger ownership checked — genuinely external; the
OQ-SK2-06's trigger ownership checked, genuinely external; the
OQ-SK ledger OQ-SK-01..09 promoted 2026-09-20; new questions
continue as OQ-SK2-06..)
---
# alksocks — Open Questions
# alksocks: Open Questions
Centralized tracker. The Phase 0 OQ ledger (`docs/research/
phase-0.md`, OQ-SK-01..09) is promoted here with statuses current as
of the Phase 1 spec; new Phase 1 questions continue the OQ-SK2
numbering (SK2 = the architecture-set ledger, distinct from the
Phase 0 ledger's OQ-SK- prefix, which stays historical). Note: the
SK2 numbering is not contiguous from 01 — OQ-SK2-01..05 were
SK2 numbering is not contiguous from 01: OQ-SK2-01..05 were
consumed in working drafts before the first committed set; the
sequence starts at the numbers in use. Format per
`docs/sdd_process.md`.
## Theme 1: Wire format and phases
### OQ-SK-01: Producer dial policy — what dials the target?
### OQ-SK-01: Producer dial policy (what dials the target?)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01)
- **Status**: resolved — [ADR-004](decisions/004-dial-callback-shape.md)
- **Status**: resolved: [ADR-004](decisions/004-dial-callback-shape.md)
(the shape), [ADR-008](decisions/008-provider-defaults.md) (the
provider set)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback
half closed on the POC evidence (function-not-trait at three
seams — DialFn/EgressFactory/BindAcceptFactory, boxed halves,
seams: DialFn/EgressFactory/BindAcceptFactory, boxed halves,
ownership rides the handle). The provider-default half: the base
crate ships local-socket providers (`local`) and composed
providers (`composed`, the alktunnels direct op); no implicit
dialer — registration always takes the callbacks explicitly.
dialer; registration always takes the callbacks explicitly.
- **Cross-references**: OQ-SK-03, OQ-SK-08, ADR-004, ADR-008
### OQ-SK-03: UDP ASSOCIATE without binding
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-03)
- **Status**: resolved — [ADR-003](decisions/003-udp-handle-swap.md)
- **Status**: resolved: [ADR-003](decisions/003-udp-handle-swap.md)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13,
POC #2 validated 2026-09-14). The handle swap: one channel per
@@ -52,15 +52,15 @@ sequence starts at the numbers in use. Format per
dedup pinned by test).
- **Cross-references**: OQ-SK-01, ADR-003, ADR-008
### OQ-SK-08: BIND — the accept-side command
### OQ-SK-08: BIND (the accept-side command)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-08)
- **Status**: resolved — [ADR-007](decisions/007-bind-in-base.md)
- **Status**: resolved: [ADR-007](decisions/007-bind-in-base.md)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15).
BIND ships in the base crate, phase-model style; the accept
variants: local listener (default, faithful), composed
(alktunnels bind-first listen + forwarded op — vanilla-fidelity
(alktunnels bind-first listen + forwarded op, vanilla-fidelity
since the graduation), wrapper-aware (in-band default). The
establishment bound covers only the open-op round trip; the
accept wait lives in the teardown-governed pump phase.
@@ -68,10 +68,10 @@ sequence starts at the numbers in use. Format per
## Theme 2: Crate architecture
### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth
### OQ-SK-02: Auth mapping (identity seam vs in-band SOCKS auth)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-02)
- **Status**: resolved — [ADR-005](decisions/005-auth-split-by-door.md)
- **Status**: resolved: [ADR-005](decisions/005-auth-split-by-door.md)
- **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1; the posture settled
2026-09-13). "Both," split by the door: producer = identity seam
@@ -85,7 +85,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-04: fast-socks5 wasm posture
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-04)
- **Status**: resolved — [ADR-011](decisions/011-inline-vendor.md)
- **Status**: resolved: [ADR-011](decisions/011-inline-vendor.md)
(the extraction mechanics), [ADR-013](decisions/
013-targeted-fork.md) (the ownership posture)
- **Priority**: high
@@ -97,14 +97,14 @@ sequence starts at the numbers in use. Format per
changes (BIND, `new_reply` publication); fork point = the tree as
of the extraction-review commit (`5b18d4d`), module `src/rfc1928/`;
differential testing against `/workspace/fast-socks5` (behavioral
reference — the cheap upstream-fix path closes; a future upstream
reference; the cheap upstream-fix path closes; a future upstream
fix is a hand-port, guarded by the suite).
- **Cross-references**: ADR-002, ADR-009, ADR-011, ADR-013
### OQ-SK-07: POC register
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07)
- **Status**: resolved — the register is closed (process artifact,
- **Status**: resolved: the register is closed (process artifact,
not a design question)
- **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). All POCs resolved: #1, #2,
@@ -117,13 +117,13 @@ sequence starts at the numbers in use. Format per
### OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-09)
- **Status**: resolved — [ADR-010](decisions/010-channel-capacity.md)
- **Status**: resolved: [ADR-010](decisions/010-channel-capacity.md)
- **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected
permanently); assembly-layer config is the mechanism; the crate
docs carry the proxy-workload recommendation with the memory
arithmetic; in-channel port multiplexing rejected (the rationale +
the revisit trigger recorded — the additive escape is a new
the revisit trigger recorded; the additive escape is a new
multiplexed ALPN).
- **Cross-references**: OQ-SK2-08, ADR-010
@@ -132,7 +132,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-05: Target policy and egress scoping
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05)
- **Status**: resolved — [ADR-006](decisions/006-access-control.md)
- **Status**: resolved: [ADR-006](decisions/006-access-control.md)
(the gate posture), [ADR-012](decisions/012-target-policy.md)
(target policy + the virtual-subnet layer)
- **Priority**: high
@@ -147,14 +147,14 @@ sequence starts at the numbers in use. Format per
### OQ-SK-06: noq client support
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-06)
- **Status**: resolved — transferred to the alknet rewrite (the
- **Status**: resolved: transferred to the alknet rewrite (the
2026-09-14 scope decision)
- **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). The quinn/noq
`AsyncUdpSocket` client wrapper is alknet's ancestor-shaped
component (ADR-090 + the quinn-proxy POC live there); it neither
produces nor consumes channels. This crate's client story is the
channels-native sessions (POC-validated) — complete without it.
channels-native sessions (POC-validated), complete without it.
- **Cross-references**: ADR-002 (the base), phase-0 §Scope
### OQ-SK2-06: The sandboxed-wasm-consumer adapter story
@@ -166,27 +166,27 @@ sequence starts at the numbers in use. Format per
- **Priority**: low
- **Impacts**: how much wasm surface to exercise in the default
crate (a product/docs input). Blocks nothing in the protocol
design — the wasm-clean invariant (ADR-009) holds regardless.
- **Blocked on**: a concrete sandboxed-adapter use case — a TS/Python
design; the wasm-clean invariant (ADR-009) holds regardless.
- **Blocked on**: a concrete sandboxed-adapter use case: a TS/Python
(or other) adapter embedding the alksocks consumer session in a
wasm runtime. Tracker task id:
`architecture/oq-sk2-06-wasm-adapter-story`
(`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`).
- **Trigger ownership (checked 2026-09-28)**: the trigger is
genuinely external — no planned suite-internal surface (alktty,
genuinely external: no planned suite-internal surface (alktty,
alktunnels, alkcall) needs a sandboxed alksocks consumer, and the
`alknodes` assembly concept carries no named wasm-adapter plan.
Nothing suite-side waits on this OQ, and this OQ waits on nothing
suite-side (the adapter's substrate — the wasm-clean default crate
— is complete and continuously gate-verified; a downstream glue
suite-side (the adapter's substrate, the wasm-clean default crate,
is complete and continuously gate-verified; a downstream glue
crate can embed it today with zero further alksocks work). The
Schrödinger-bind check (both sides deferring to each other
forever) came up empty; the OQ stays an unowned external trigger
by verified default. Re-check the ownership claim if a product
surface names a sandboxed adapter — the amendment then converts
surface names a sandboxed adapter; the amendment then converts
this to an owned dependency.
- **Resolution**: Not yet decidable. The use case isn't concrete yet
— the adapter story is a product input whose shape would tell us
- **Resolution**: Not yet decidable. The use case isn't concrete yet:
the adapter story is a product input whose shape would tell us
whether the wasm consumer needs surface beyond the
channels-native session (e.g. bindings ergonomics). The
architecture decision (wasm-clean default, ADR-009) is already
@@ -200,7 +200,7 @@ sequence starts at the numbers in use. Format per
- **Status**: deferred(scope)
- **Door type**: two-way
- **Priority**: low
- **Impacts**: nothing in the crate — the seams already support the
- **Impacts**: nothing in the crate; the seams already support the
translation layer (the dial callback, ADR-004/ADR-012). This
tracks whether a *translation-table helper* should graduate into
the crate if a deployment materializes with concrete needs.
@@ -209,7 +209,7 @@ sequence starts at the numbers in use. Format per
at that point). Tracker task id:
`architecture/oq-sk2-07-virtual-subnet-helper`
(`tasks/architecture/oq-sk2-07-virtual-subnet-helper.md`).
- **Resolution**: Not yet decidable. The use case isn't concrete —
- **Resolution**: Not yet decidable. The use case isn't concrete:
the mechanism is assembly-layer policy today (ADR-012), and a
crate-level helper would need real deployment requirements to
shape (subnet sizing, per-identity tables, hub exposure).
@@ -236,7 +236,7 @@ sequence starts at the numbers in use. Format per
the pre-publish session's escalation (2026-09-28): the residual is
a real deployment concern (NAT is common), and the RFC's own drop
rule was unimplemented
- **Status**: partially resolved — option 2 landed in 0.1.0 (commit
- **Status**: partially resolved: option 2 landed in 0.1.0 (commit
`31fb11c`); option 3 deferred (below)
- **Door type**: two-way (additive; the current shape is RFC-faithful)
- **Priority**: low (post-fix: the common cases are covered)
@@ -244,23 +244,23 @@ sequence starts at the numbers in use. Format per
**IP** on the first parsed datagram and sends replies to the
**latest-seen port** from that IP (the NAT port-rebind case keeps
receiving); datagrams from other source IPs are dropped (RFC 1928's
MUST, previously unimplemented — the spoof primitive shrank from
MUST, previously unimplemented, so the spoof primitive shrank from
any-source to same-IP off-path). The **remaining residual**: a full
client IP change mid-association (a NAT *address* rebind) stops
receiving replies — the RFC's expected-peer posture (the RFC pins
receiving replies (the RFC's expected-peer posture: the RFC pins
the expected IP per association too).
- **Option 3 (the complete fix, deferred)**: per-ASSOCIATE relay
sockets spawned by the TCP door, with the expected peer IP derived
from the TCP control connection's remote address (the RFC's
"acquire from the SOCKS server" reading) — RFC-faithful end to end
"acquire from the SOCKS server" reading): RFC-faithful end to end
and kills the same-IP spoof residual too. Deferred because it
restructures the doors' client-correlation story (currently the
loose assembly-layer wiring seam) and needs its own ADR — not
loose assembly-layer wiring seam) and needs its own ADR; not
buildable speculatively without a deployment that actually spells
the wildcard bind *and* suffers address-level rebinds.
- **Blocked on**: a concrete wildcard-bind deployment with
address-level client mobility (QUIC connection migration is the
likely driver — the channels association survives transport
likely driver: the channels association survives transport
migration while a pinned IP does not).
- **Cross-references**: review 003 S-1, ADR-003 (the handle swap),
ADR-014 (the doors own the accept-path defense), OQ-SK-03
@@ -271,11 +271,11 @@ sequence starts at the numbers in use. Format per
|----|--------|------------|--------------|
| OQ-SK2-06 | deferred(scope) | a concrete sandboxed-adapter use case | `architecture/oq-sk2-06-wasm-adapter-story` |
| OQ-SK2-07 | deferred(scope) | a concrete virtual-subnet deployment | `architecture/oq-sk2-07-virtual-subnet-helper` |
| OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | — (the posture is recorded here; a tracker task spawns with the deployment driver) |
| OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | none (the posture is recorded here; a tracker task spawns with the deployment driver) |
## References
- Phase 0 ledger: `docs/research/phase-0.md` §Open Questions
(OQ-SK-01..09 — the full reasoning trails live there)
(OQ-SK-01..09; the full reasoning trails live there)
- The SDD process: `docs/sdd_process.md` (the OQ format; the
deferred-OQ blocker-task half)
+47 -47
View File
@@ -1,14 +1,14 @@
---
status: draft
last_updated: 2026-09-28 (OQ-SK2-09 indexed — the UDP door's peer-pin
posture; OQ-SK2-06's trigger ownership checked; 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
last_updated: 2026-09-28 (OQ-SK2-09 indexed: the UDP door's peer-pin
posture; OQ-SK2-06's trigger ownership checked; 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
# alksocks: Overview
A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall
channels: an arbitrary-egress proxy service that **never binds a port
@@ -16,7 +16,7 @@ unless explicitly configured to**, carrying the RFC conversation
inside a channels data channel like any other produced resource. 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) —
flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy),
the one alktunnels explicitly deferred to composition. This document
covers the crate's purpose, the resource model in brief, dependencies,
the ALPN, feature gates, and the module map; component details are in
@@ -33,9 +33,9 @@ front doors:
`channels/socks5/sub` open op (registered via
`ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049). The establisher is a *session gate* (validates the
opener identity; the SOCKS5 target is unknown at open time); the
opener identity; the SOCKS5 target is unknown at open time); the
open handler runs the RFC 1928 state machine in-stream over the
channel's `BiStream` — the forked RFC 1928 core
channel's `BiStream`; the forked RFC 1928 core
([rfc1928.md](rfc1928.md)) drives negotiation, command read, and
replies; the dial/egress/accept seams (ADR-004) supply the I/O
planes; `alkcall::channels::pump_bidi` (ADR-050) is the data plane
@@ -46,14 +46,14 @@ front doors:
the associate session and the BIND reply#2 reader). *Wrapper-aware*
means a client that knows the in-band conventions (the sentinel
BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client.
Optionally it
exposes the service locally: the **front door** — a real
`socks5://127.0.0.1:port` endpoint relaying each accepted
connection into a channel (the ssh `-D` local-exposure shape; the
path curl/browser/tun2proxy point at).
Optionally it
exposes the service locally through the **front door**, a real
`socks5://127.0.0.1:port` endpoint relaying each accepted
connection into a channel (the ssh `-D` local-exposure shape; the
path curl/browser/tun2proxy point at).
- **The optional producer local backend**: the producer may serve
from a real listener (feature-gated; the same door shape in the
other direction — vanilla clients → a locally-served SOCKS5
other direction: vanilla clients → a locally-served SOCKS5
resource).
The defining posture (the crate's reason to exist as a separate
@@ -76,7 +76,7 @@ binding decision belongs to the caller.
SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the
loop.
- The substrate-agnostic protocol layer: the forked RFC 1928 core is
generic over `T: AsyncRead + AsyncWrite + Unpin` — a channels
generic over `T: AsyncRead + AsyncWrite + Unpin`; a channels
`BiStream` drops in directly (POC #1), a `TcpStream` drops in at
the local backends. Neither substrate leaks into the protocol
layer (the alktty `TtyBackend` / alktunnels pump-halves
@@ -89,7 +89,7 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
1. **Open (the establishment phase).** The consumer opens
`channels/socks5/sub` with trivial params (ADR-001). The
establisher is the session gate — it checks/records the opener
establisher is the session gate: it checks/records the opener
identity and does session-level policy; it never sees a target.
Failure is a typed `channel:open_failed` (alkcall ADR-049); no
phantom channel.
@@ -99,8 +99,8 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
dispatch: CONNECT/ASSOCIATE require `socks5:open`; BIND requires
`socks5:bind` (the command gate, ADR-006).
3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed
halves; `pump_bidi` two-pumps channel ↔ target — raw
pass-through, 0 B overhead. ASSOCIATE: the reply's BND.ADDR is a
halves; `pump_bidi` two-pumps channel ↔ target (raw
pass-through, 0 B overhead). ASSOCIATE: the reply's BND.ADDR is a
pure RFC handle (the sentinel in-band); the datagram stage rides
`[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to
the egress seam. BIND: reply#1 → quiet wait → exactly one accept
@@ -119,34 +119,34 @@ protocol layer; crate-level vocabulary is producer/consumer.
## Dependencies
```
alksocks (default — wasm-clean)
alksocks (default: wasm-clean)
├── alkcall 0.10.1 (core types: Connection, ProtocolHandler, BiStream,
│ AuthContext, Identity, AccessControl, OwnershipProvider;
│ channels: ChannelCore/ChannelClient,
│ register_openable_with_establisher, pump_bidi,
│ Establishment/ChannelPlan + reply projection,
│ ServingConfig identity seam — CF-005/006)
│ the ServingConfig identity seam (CF-005/006))
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── serde/serde_json (params schema), thiserror (error surface), tracing
└── (no backend deps — sockets live in feature-gated modules)
└── (no backend deps: sockets live in feature-gated modules)
alksocks (local feature) — non-wasm by design
alksocks (local feature): non-wasm by design
└── adds: tokio/net + socket2 (the feature's own socket providers) and
the first-party local backends + front doors
alksocks (composed feature)
└── adds: alktunnels 0.4.1 (the direct op egress; the bind-first
listen accept; ADR-008's composed providers) — its default
listen accept; ADR-008's composed providers), whose default
crate is wasm-clean; the composed providers use only the
ungated channels surface (verify with the wasm check)
```
Same posture as alktty/alktunnels: the default crate is
protocol-only and wasm-clean — the RFC 1928 core is an *extraction*
protocol-only and wasm-clean; the RFC 1928 core is an *extraction*
of fast-socks5's `T`-generic subset, containing zero kernel-net code,
so there is no `net` feature at all (ADR-011 §Amendment);
substrate I/O rides `local` alone (ADR-009 §Amendment).
`fast-socks5` is NOT a Cargo dependency — it was extracted in-tree
`fast-socks5` is NOT a Cargo dependency: it was extracted in-tree
and forked (ADR-013); `/workspace/fast-socks5` stays as the
differential-testing behavioral reference.
@@ -154,44 +154,44 @@ differential-testing behavioral reference.
| Feature | Contents | Wasm |
|---------|----------|------|
| *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013 — codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types — protocol only; zero kernel-net code, wasm-clean by construction | yes |
| *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013: codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types; protocol only, zero kernel-net code, wasm-clean by construction | yes |
| `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes); brings `tokio/net` + `socket2` into the graph | no (build error on wasm, the alktty posture) |
| `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept — adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface — verify with the wasm check at implementation) |
| `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept; adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface: verify with the wasm check at implementation) |
## Module Map
```
src/
lib.rs — re-exports; crate docs
rfc1928/ — the RFC 1928 core: the targeted fork of
lib.rs : re-exports; crate docs
rfc1928/ : the RFC 1928 core: the targeted fork of
fast-socks5's T-generic subset (ADR-013;
extraction per ADR-011; fork point noted
in-tree): consts, codecs, errors, typestate,
client use_stream path; the fork's two
carried changes (BIND reply surface,
new_reply publication); no kernel-net code
params.rs — the trivial params schema + SOCKS5_OPEN_SCOPE/
params.rs : the trivial params schema + SOCKS5_OPEN_SCOPE/
SOCKS5_BIND_SCOPE constants (ADR-001/006)
wire.rs — the ASSOCIATE datagram frame codec, re-implemented
wire.rs : the ASSOCIATE datagram frame codec, re-implemented
in first-party code shaped on alktunnels' length
codec (ADR-003; framing is wire-layer, applied
exactly once)
producer.rs — the open op spec (scope-gated), the session-gate
producer.rs : the open op spec (scope-gated), the session-gate
establisher, the command-dispatch pump handler
(typestate driving + the three seams), teardown
consumer.rs — the channels-native session types (connect/associate/
consumer.rs : the channels-native session types (connect/associate/
bind reply#2 reader), teardown ownership
door.rs — the front doors (feature `local`): the raw-pipe
door.rs : the front doors (feature `local`): the raw-pipe
bridge + the RFC 1929 cred-bearing door + the
credential→identity mapping (ADR-005); the UDP
front door's RFC codec boundary (ADR-003)
egress.rs — the egress/accept seam types (DialFn/EgressFactory/
BindAcceptFactory contracts — ADR-004)
local/ — feature `local`: the socket-backed provider
egress.rs : the egress/accept seam types (DialFn/EgressFactory/
BindAcceptFactory contracts, ADR-004)
local/ : feature `local`: the socket-backed provider
constructors (dial/egress/accept)
composed/ — feature `composed`: the alktunnels-composed
composed/ : feature `composed`: the alktunnels-composed
providers (direct-op egress, bind-first accept)
error.rs — SocksError + the typed open-error surface
error.rs : SocksError + the typed open-error surface
(alkcall ADR-049 §4)
```
@@ -205,7 +205,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}` — the RFC conversation is the data plane |
| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}`: the RFC conversation is the data plane |
| [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (extracted + forked, `src/rfc1928/`); faithful reply-code mapping |
| [003](decisions/003-udp-handle-swap.md) | The handle swap | one channel per association; `{addr, data}` frames in-band; RFC codec at front doors only |
| [004](decisions/004-dial-callback-shape.md) | The dial-callback shape | function-not-trait at three seams (dial/egress/accept); ownership rides the handle |
@@ -213,7 +213,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [006](decisions/006-access-control.md) | Access-control posture | `socks5:open` gate + `socks5:bind` command gate; target policy = the dialer |
| [007](decisions/007-bind-in-base.md) | BIND in the base crate | phase model extended (reply#1 → quiet wait → accept → reply#2 → pass-through); additive |
| [008](decisions/008-provider-defaults.md) | Provider defaults | local-socket + composed (direct op) providers, both feature-gated; no implicit dialer |
| [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction — ADR-011 §Amendment); default crate wasm-clean |
| [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction; ADR-011 §Amendment); default crate wasm-clean |
| [010](decisions/010-channel-capacity.md) | Channel capacity | no wire change; assembly-layer config + the documented proxy-workload recommendation |
| [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 |
@@ -225,13 +225,13 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document:
- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope)
— a product input, nothing waits on it; the trigger is external by
- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope);
a product input, nothing waits on it; the trigger is external by
verified default)
- **OQ-SK2-08**: recommended proxy-workload cap figure (open — a docs
- **OQ-SK2-08**: recommended proxy-workload cap figure (open, a docs
tuning note; ADR-010 pins the posture)
- **OQ-SK2-09**: the UDP door's peer-pin posture (option 2 — the
same-IP lock + reply-port refresh — landed in 0.1.0; option 3, the
- **OQ-SK2-09**: the UDP door's peer-pin posture (option 2, the
same-IP lock + reply-port refresh, landed in 0.1.0; option 3, the
control-conn-derived expected peer, deferred on a concrete
wildcard-bind deployment)
+37 -37
View File
@@ -1,10 +1,10 @@
---
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)
decided timeout posture, ADR-014; Phase 1 initial draft 2026-09-20)
---
# alksocks — The Producer Half
# 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)
@@ -22,33 +22,33 @@ Registration uses `ChannelCore::register_openable_with_establisher`
- 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);
(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
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) —
`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
(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
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
`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
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
@@ -57,28 +57,28 @@ 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)` —
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:
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)`
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.
→ `ConnectionNotAllowed` 0x02); no phantom data plane.
- **TCP BIND** → **the command gate**: require `socks5:bind`
from the per-call opener identity (ADR-006) — an identity
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,
- 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).
@@ -101,7 +101,7 @@ substrate-free `serve_socks5_over`):
Contracts (POC-pinned, load-bearing):
- **Ownership rides the handle structurally** — handle payloads own
- **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).
@@ -109,21 +109,21 @@ Contracts (POC-pinned, load-bearing):
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
- **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
- `SOCKS5_OPEN_SCOPE = "socks5:open"`: the open op's
`required_scopes`; egress capability (CONNECT + ASSOCIATE).
- `SOCKS5_BIND_SCOPE = "socks5:bind"` — the BIND command gate,
- `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
- Optional: consult `OwnershipProvider` for resource ownership, a
registration-time choice.
## Producer-Side Local Backend (feature `local`, optional)
@@ -131,40 +131,40 @@ Contracts (POC-pinned, load-bearing):
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 —
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
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
`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
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) — the two constructor knobs.
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
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; the pump handler's identity-derived gate does not apply).
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
.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, &registry, auth, witness) — the establisher rides
// register(&core, &registry, auth, witness): the establisher rides
// the registration (the optional witness is the CF-006 test probe).
producer.register(&core, &registry, auth, None)?;
```
+44 -44
View File
@@ -1,22 +1,22 @@
---
status: draft
last_updated: 2026-09-24 (the keep-or-cut sweep + the refusal
vocabulary variant — the fork's owned post-fork changes section;
review 002 N-1/N-4; the targeted fork 2026-09-22 — ADR-013
supersedes ADR-011's maintenance posture; the module is
vocabulary variant: the fork's owned post-fork changes section;
review 002 N-1/N-4; the targeted fork 2026-09-22, with ADR-013
superseding ADR-011's maintenance posture; the module is
`src/rfc1928/`, the fork point is `5b18d4d`, the lint-allow posture
dissolves; extraction review F-1: `ReplyError::from_u8`'s panic arm
mapped to `GeneralFailure`; Phase 1 initial draft 2026-09-20)
---
# alksocks — The RFC 1928 Core (the targeted fork)
# alksocks: The RFC 1928 Core (the targeted fork)
The crate owns its RFC 1928 protocol code in-tree: the **targeted
fork** of fast-socks5's `T`-generic subset ([ADR-013](decisions/
013-targeted-fork.md); the extraction mechanics in
[ADR-011](decisions/011-inline-vendor.md); OQ-SK-04's resolution).
This document is the fork's scope — what was extracted, what was
not, the deliberate modifications — and the maintenance posture.
This document is the fork's scope (what was extracted, what was
not, the deliberate modifications) and the maintenance posture.
The protocol base decision is ADR-002; the feature gating is
ADR-009.
@@ -30,25 +30,25 @@ review-vendor-extraction.md`) is the recorded fidelity gate.
The code arrived by **extraction** (ADR-011 §Amendment): copy *only*
the `T`-generic inventory from fast-socks5 v1.0.0 into the tree; the
kernel-net handlers are **not copied** — they stay in
kernel-net handlers are **not copied**; they stay in
`/workspace/fast-socks5` (the read-only reference checkout).
Concretely: POC #4's cfg map (its five-file inventory of what is
kernel-net-gated) is the base of the extraction recipe — copy what
kernel-net-gated) is the base of the extraction recipe: copy what
POC #4 left *ungated*, minus the two recorded exclusions (the
`socks4` module and the legacy `Config`/`Authentication` layer —
see the not-forked inventory). The extraction contains zero
`socks4` module and the legacy `Config`/`Authentication` layer; see
the not-forked inventory). The extraction contains zero
kernel-net code, so it is wasm-clean by construction; there is no
in-tree `net`-shaped feature gate.
Post-extraction (ADR-013), the region is **owned code**: project
conventions apply unconditionally, there is no upstream tree to
stay reconcilable with, and the module is `src/rfc1928/` — named
stay reconcilable with, and the module is `src/rfc1928/`, named
for what it is (the RFC 1928 wire vocabulary and state machines),
not for an ownership relation it no longer has.
## What was Extracted (now the forked core)
From fast-socks5 **v1.0.0** (MIT, third-party upstream —
From fast-socks5 **v1.0.0** (MIT, third-party upstream;
`/workspace/fast-socks5` is the read-only reference checkout; the
fork point is noted in the module docs and the license notice):
@@ -58,34 +58,34 @@ fork point is noted in the module docs and the license notice):
| The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | the negotiation machinery (the crate uses `accept_no_auth` in-band; the traits stay) | `T`-generic |
| `transfer` + `wait_on_tcp` | the typestate's own two-pump seam (the crate's data plane is `pump_bidi`; `transfer` is the interception-point shape) | `T`-generic |
| The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | pure; `new_reply` published in-tree (fork change) |
| The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit) — the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic |
| The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit); the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic |
| The non-net test modules (where extractable as unit tests) | RFC edge-case coverage rides in-tree | adjusted to the crate's test layout |
## What was NOT Forked (the cut, re-read as the hand-port map)
These stay in `/workspace/fast-socks5` (the differential-testing
reference); the table below records the boundary — under the fork
reference); the table below records the boundary; under the fork
posture it is the **hand-port map**: what to port from the
reference checkout if a future need surfaces (port it, adapt it to
the crate's conventions, let the differential suite guard it —
the crate's conventions, let the differential suite guard it;
ADR-013). Rows 1–5 are POC #4's five-file cfg map (what POC #4
gated is what was not extracted); the socks4 row and the legacy
`Config`/`Authentication` layer are the recipe's two additions —
`Config`/`Authentication` layer are the recipe's two additions:
the socks4 module is out of RFC 1928 scope, and the legacy auth
layer was left ungated by POC #4 (it compiles without net) but no
extraction consumer reached it. None of it is forked:
| Upstream file | Region NOT in `src/rfc1928/` |
|---|---|
| `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module — plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) |
| `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module; plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) |
| `src/client.rs` | `Socks5Datagram` (struct + impl), the `Socks5Stream<TcpStream>` convenience impl (`connect`/`connect_with_password`/`connect_raw`) |
| `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay — `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) |
| `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay; `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) |
| `src/util/target_addr.rs` | the `resolve_dns` method (the codec stays) |
| `src/lib.rs` | the net-heavy test module (consts/errors/UDP-header codec stay) |
| `src/socks4/` | the whole module (upstream's separate default-off `socks4` cargo feature; out of RFC 1928 scope) |
**Why none of it is needed in-tree** (ADR-011 §Amendment): no crate
consumer routes through the kernel-net region — the producer drives
consumer routes through the kernel-net region; the producer drives
the typestate directly (the ASSOCIATE path is the POC #2
typestate-driving shape; `run_udp_proxy_custom` is unusable on
channels regardless), the consumer sessions use `use_stream` plus
@@ -99,16 +99,16 @@ reference checkout, as an additive decision (ADR-013).
## The Deliberate Modifications (the fork's diff vs upstream v1.0.0)
The fork's diff vs upstream v1.0.0 is deliberate and bounded —
The fork's diff vs upstream v1.0.0 is deliberate and bounded:
**two changes touch the forked code**; the other two of the original
four dissolve with the kernel-net region:
1. **BIND** — absent upstream (every handler refuses
1. **BIND**: absent upstream (every handler refuses
`Socks5Command::TCPBind`). The crate drives the typestate itself
for the BIND shape (ADR-007); the fork carries the shape in-tree
(`reply_success`'s `sock_addr` as the reply#1 carrier; the
reply#2 writer using the published `new_reply`).
2. **`new_reply` publication** — the reply encoder becomes public
2. **`new_reply` publication**: the reply encoder becomes public
(a `reply(code, addr)`-shaped constructor) so the BIND reply#2
writer doesn't re-encode the 10/22-byte reply form.
@@ -116,10 +116,10 @@ Dissolved: the `net` feature gate (no net code in-tree) and the
`run_udp_proxy_custom` bind seam (the handler isn't forked).
Plus the **owned-code post-fork changes** (the fork is first-class
code — extend this list, never replace it; convention 17):
code; extend this list, never replace it; convention 17):
- **The keep-or-cut sweep** (review 002 N-1): the producer-replaced
machinery is cut from the typestate — the `auth_method_enums!`
machinery is cut from the typestate: the `auth_method_enums!`
macro (upstream's enum generator) with its generated
`StandardAuthentication` enum + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s
@@ -130,11 +130,11 @@ code — extend this list, never replace it; convention 17):
typestate's). Kept with note (the hand-port map, ADR-013):
`ConnectError` (constructed nowhere in-tree; the dial-error →
reply-code mapping a future kernel-net port reuses) and
`transfer`/`wait_on_tcp` (differential-suite-consumed — their
`transfer`/`wait_on_tcp` (differential-suite-consumed; their
semantics stay pinned there). The upstream `Bug` variant is
likewise kept, its display intact.
- **The refusal vocabulary variant** (review 002 N-4):
`SocksServerError::Refused(&'static str)` — the honest
`SocksServerError::Refused(&'static str)`: the honest
log/observability vocabulary for the producer's in-stream policy
refusals (the command gate + the four seam-refusal sites). Upstream
has no such variant; it is an owned addition, never reaching the
@@ -144,7 +144,7 @@ code — extend this list, never replace it; convention 17):
Plus the provenance notes: the fork point (upstream v1.0.0,
extraction `57b107b..5b18d4d`, fork at the review commit) and the
license notice (MIT, extended — ADR-013's license mechanics).
license notice (MIT, extended; ADR-013's license mechanics).
Plus the **extraction-time adjustments** (mechanical edits applied
at extraction, before the fork):
@@ -154,8 +154,8 @@ at extraction, before the fork):
out of RFC 1928 scope). `SocksError`'s `ReplySocks4Error` variant
(also `#[cfg(feature = "socks4")]` upstream) goes with it.
- The legacy `Config<A>`/`Authentication` auth layer is out
(ungated upstream, but consumed only by the cut legacy/net region
— `Socks5Server`/`Socks5Socket`/the `run_*` handlers; the
(ungated upstream, but consumed only by the cut legacy/net region:
`Socks5Server`/`Socks5Socket`/the `run_*` handlers; the
typestate's own `accept_no_auth`/`accept_password_auth` are the
crate's in-band auth surface). With it go `async-trait` (its four
upstream uses are all on this layer), the example auth impls
@@ -165,14 +165,14 @@ at extraction, before the fork):
- Upstream `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing`.
- Upstream `anyhow` error wrapping (`Context` / `Other(#[from]
anyhow::Error)`) is dropped — the crate's error surface is
anyhow::Error)`) is dropped; the crate's error surface is
`thiserror`-typed; the forked `SocksError`/`SocksServerError`
stay the wire-level vocabulary.
- The panic paths in the extracted region are translated (AGENTS.md
convention 2 — no panics in library code): `ReplyError::from_u8`'s
convention 2: no panics in library code): `ReplyError::from_u8`'s
upstream `unreachable!()` arm for codes outside the RFC table
maps unassigned codes (`0x09`–`0xFF`, RFC 1928 §6) to
`GeneralFailure` instead — the value comes off the wire
`GeneralFailure` instead; the value comes off the wire
(`Socks5Stream::read_request_reply` feeds the raw reply byte
there), so a misbehaving peer surfaces as a typed error, never a
panic (the extraction review's F-1 correction).
@@ -186,28 +186,28 @@ at extraction, before the fork):
a request header would corrupt the conversation; byte-identical on
a healthy stream). `reply_success`/`reply_error` were likewise
converted at the fork review (001 F-1): replies are small,
single-shot frames that must be all-or-nothing — a partial write
single-shot frames that must be all-or-nothing; a partial write
desynchronizes the conversation silently. (The
`clippy::unused_io_amount` lint — deny-by-default — does flag bare
`clippy::unused_io_amount` lint, deny-by-default, does flag bare
`.write()` on `AsyncWriteExt`, but the `err_when`-chaining idiom
type-infers the result through a generic blanket impl and suppresses
it; the linter is not a substitute for the conversion.)
Plus the **post-fork convention fixes** (ADR-013 — the lint-allow
Plus the **post-fork convention fixes** (ADR-013; the lint-allow
posture dissolved, project conventions applied): the module-level
`#[allow]`s removed and the suppressed smells fixed
(`u8::max_value()` → `u8::MAX`, needless lifetimes elided,
`match_like_matches_macro` applied, `io::Error::new(Other)` →
`io::Error::other`, `wrong_self_convention` fixed on `AuthMethod::
new`, the `ToSocketAddrs` error string re-worded — it no longer
names the cut `resolve_dns()` — and the `#[allow(dead_code)]` on
new`, and the `ToSocketAddrs` error string re-worded (it no longer
names the cut `resolve_dns()`); the `#[allow(dead_code)]` on
`AuthenticationMethod` swept). These are one-time, recorded for
completeness; the fork is first-class code thereafter.
## Maintenance Posture
- **The fork is first-class code**: it co-evolves with the wrapper
under the project conventions, unconditionally — no second
under the project conventions, unconditionally: no second
instruction set, no adjustment-list apparatus, no verbatim
preservation (ADR-013).
- **Differential testing**: `/workspace/fast-socks5` stays the
@@ -215,19 +215,19 @@ completeness; the fork is first-class code thereafter.
domain addressing, fragmentation). The crate's test suite covers
the edge cases the forked code must keep passing; when in doubt,
diff behavior against the reference checkout. The kernel-net
region lives there too — the reference checkout is the only place
region lives there too; the reference checkout is the only place
the full upstream surface exists.
- **Upstream posture**: fast-socks5 is third-party; nothing waits
on it, nothing is absorbed from it. A future upstream fix that
matters is **hand-ported** against the reference checkout and
guarded by the differential suite (ADR-013's honest cost —
guarded by the differential suite (ADR-013's honest cost,
assessed low-risk: a quiet upstream, a well-trodden RFC surface).
The offering-a-PR-upstream courtesy item is retired with the
vendor posture (the net gate dissolved with the kernel-net
region; there is no in-tree need to offer).
- **Provenance is permanent**: the module docs and the bundled
license notice always name the origin (fast-socks5 v1.0.0, the
extraction range, the fork point) — orthogonal to ownership, kept
extraction range, the fork point), orthogonal to ownership, kept
so future readers can always see where the code came from
(ADR-013).
- **No second codec, ever**: the forked codecs are the only RFC
@@ -252,10 +252,10 @@ None currently blocking this document.
- OQ-SK-04 (the resolved posture), OQ-SK-08 (BIND + `new_reply`),
OQ-SK-03 (the bind seam)
- POC #4 (`poc-connect-wasm-findings.md` — the measured gate), POC
#5 (`poc-bind-findings.md` — the BIND driving + the `new_reply`
- POC #4 (`poc-connect-wasm-findings.md`: the measured gate), POC
#5 (`poc-bind-findings.md`: the BIND driving + the `new_reply`
finding), POC #2 (the typestate-driving shape for ASSOCIATE)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate; F-1)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral reference);
- `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral reference);
`docs/research/iroh-socks5-eval.md` (why not the alternative base)
+41 -41
View File
@@ -1,19 +1,19 @@
---
status: draft
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided —
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
initial draft 2026-09-20; the POC-validated wire shapes promoted
into the normative spec)
---
# alksocks — Wire Format
# alksocks: Wire Format
The `alk/socks5` wire surface. Two layers: the **open op** (JSON
params on the channels call plane — trivial here, ADR-001) and the
params on the channels call plane; trivial here, ADR-001) and the
**data plane** (the RFC 1928 conversation + the data streams inside
the channel `BiStream`). The channels layer strips its own 8-byte
header transparently (alknet ADR-093 / alkcall ADR-035); alksocks
owns everything inside the `BiStream` — which is, per the phase
owns everything inside the `BiStream`, which is, per the phase
model (ADR-003/ADR-007), the RFC conversation itself plus raw
pass-through, with exactly one framed stage (ASSOCIATE datagrams).
@@ -23,14 +23,14 @@ Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.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
itself; this document covers the channels-specific surface and the
phase model's stage boundaries.
## The Open Op (`channels/socks5/sub`)
`Sub`-typed operation (alkcall ADR-047): the reply carries
`{ "channel_id": <u32> }` once; everything else — the entire RFC 1928
conversation and the data plane — flows on the channel's `BiStream`,
`{ "channel_id": <u32> }` once; everything else (the entire RFC 1928
conversation and the data plane) flows on the channel's `BiStream`,
never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
### Input (params)
@@ -41,21 +41,21 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
- Trivial: the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation); the establisher is a session gate, not
a dial (POC #1's structural finding — ADR-004).
a dial (POC #1's structural finding, ADR-004).
- Schema: `{type: object}` with `additionalProperties: false`. Empty
params stay valid forever; a future per-session option would be an
additive schema extension (ADR-001).
### Reply / errors
- Success: `{ "channel_id": <u32> }` — the establishment phase (the
- Success: `{ "channel_id": <u32> }`: the establishment phase (the
session gate) succeeded, the open handler is running.
- Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `reason ∈ dial_failed` /
`details: { reason, message }` where `reason ∈ dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs
before the wrapper (`FORBIDDEN` for a scope-less identity —
POC #1). **A failed open never returns a `channel_id`** — no
before the wrapper (`FORBIDDEN` for a scope-less identity;
POC #1). **A failed open never returns a `channel_id`**: no
phantom channel. Session-level refusal is always channels-level;
*target-level* refusal happens later, in-stream, as an RFC reply
code (ADR-004).
@@ -71,7 +71,7 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
RFC 1928 is one command per connection, and control and data never
interleave in either direction. The channel's `BiStream` therefore
has a **phase structure with no mode byte** — the reply itself
has a **phase structure with no mode byte**: the reply itself
synchronizes the switch (POC #2 confirmed post-reply garbage is
refused structurally; the demux-vs-phase trade is resolved by
ADR-003/ADR-007):
@@ -88,18 +88,18 @@ ADR-003/ADR-007):
any post-reply control byte parses as a garbage ATYP and fails the
association loud (`udp_undecodable_frame_ends_association`, POC
#2). BIND: any byte in the quiet wait ends the session with no
data plane (POC #5 — the RFC 1928 §3 reading).
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")
("no in-band control path, ever; the escape hatch is a new ALPN")
holds: no KEEPALIVE, no error frames, no flow-expiry frames. No
idle/inactivity vocabulary either — every phase's lifetime is
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
- **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).
## The Data Plane
@@ -107,7 +107,7 @@ ADR-003/ADR-007):
### CONNECT: raw pass-through
Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_
bistream, target_read, target_write)` (ADR-050) — the canonical
bistream, target_read, target_write)` (ADR-050), the canonical
two-pump shape (alknet ADR-078); each pump shuts down the opposite
sink on completion (half-close semantics fall out); EOF sentinels
are the channels layer's. Zero protocol framing in-band: the RFC
@@ -122,31 +122,31 @@ interception point with the parsed target. Per-target refusal:
### UDP ASSOCIATE: the handle swap + framed datagram stage
The ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is the sentinel
`127.0.0.1:0` on the in-band path — pure RFC-compatibility surface;
`127.0.0.1:0` on the in-band path (pure RFC-compatibility surface);
the channel is the relay handle (ADR-003). The datagram stage rides
the same channel:
- **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the
RFC address encoding (`TargetAddr::to_be_bytes()` — ATYP + addr +
RFC address encoding (`TargetAddr::to_be_bytes()`: ATYP + addr +
port; the RFC UDP header minus RSV/FRAG). One frame per datagram,
both directions. **A zero-length payload is a legal empty datagram
(never EOF — the F-2 invariant, shaped on alktunnels ADR-003's
codec, re-implemented in first-party `src/wire.rs`); `len == 0` —
zero inner bytes — is unreachable from a compliant peer, since
(never EOF; the F-2 invariant, shaped on alktunnels ADR-003's
codec, re-implemented in first-party `src/wire.rs`); `len == 0`
(zero inner bytes) is unreachable from a compliant peer, since
`addr` is always ≥ 7 bytes, so the operative invariant lives at
payload level.** The length covers
`addr + data`, not data alone — the joint bound is the
`addr + data`, not data alone; the joint bound is the
constraint:** a frame is rejected (`Oversize`, at frame time)
whenever `len(addr) + len(data) > 65535`. The UDP payload bound
alone (65507) does *not* fit u16 unconditionally: a 65507-byte
datagram to a 255-character domain is 65507 + 259 (max domain-form
address: ATYP + length byte + 255 + port) = 65766 bytes of frame
payload — a legal RFC 1928 datagram that would overflow u16. The
payload, a legal RFC 1928 datagram that would overflow u16. The
joint rejection keeps the length field honest: max data is 65276
for domain-form destinations, 65516 for IPv6, 65528 for IPv4.
Implementations must not use unchecked u16 arithmetic on the
payload bound.
- **RSV/FRAG do not ride the tunnel wire** — fragmentation cannot be
- **RSV/FRAG do not ride the tunnel wire**: fragmentation cannot be
expressed in-band; FRAG ≠ 0 is refused at front doors (the
iroh-socks5 v1 posture).
- **The RFC header codec (`new_udp_header`/`parse_udp_request`)
@@ -157,7 +157,7 @@ the same channel:
channels-level sentinel) ends the association; no extra control
vocabulary (POC #2 test 4: a fresh associate succeeds immediately
after a half-close teardown). No inactivity timer exists at the
protocol layer — every phase (the pre-command handshake, the BIND
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
@@ -165,7 +165,7 @@ the same channel:
- **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
select into livelock — POC #2's load-bearing API finding).
select into livelock; POC #2's load-bearing API finding).
**Framing is applied exactly once, by the wire layer:**
`Egress::next_reply` returns *inner* bytes (`addr + data`); the
frame writer adds the single length prefix (the double-framing bug
@@ -178,7 +178,7 @@ the same channel:
open cost ~0.8 ms measured, per-target ACL for free).
**Per-datagram `send` is awaited inline** (the stage interleaves
sends with reply drains); a slow egress applies backpressure to
the association, not to other channels — the batch-2
the association, not to other channels; the batch-2
composed-egress author inherits this bound knowingly.
### BIND: the phase model extended (ADR-007)
@@ -187,13 +187,13 @@ the same channel:
variant's real listener address; the sentinel on the wrapper-aware
in-band path).
- **The quiet wait**: no bytes are legal; the `select!` is
single-shot with exactly two exits — the one inbound accept (from
single-shot with exactly two exits: the one inbound accept (from
the `BindAcceptFactory` seam, whose future owns the listener), or
the control stream's EOF/garbage/error (the client-gone teardown
riding the accept future's drop).
- **reply#2 carries the peer address** (the real peer on both
non-sentinel variants — local: `accept()`'s peer, byte-exact;
composed: the forwarded op's `peer` param since the bind-first
non-sentinel variants: local is `accept()`'s peer, byte-exact;
composed is the forwarded op's `peer` param since the bind-first
graduation, alktunnels `docs/research/tunnels-graduation.md`;
sentinel `0.0.0.0:0` wrapper-aware).
- **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape)
@@ -204,7 +204,7 @@ the same channel:
### Sentinels and lifecycle (all commands)
- **EOF** is the channels-level zero-length chunk — per-direction
- **EOF** is the channels-level zero-length chunk, per-direction
(half-close); written by `pump_bidi`'s shutdown-on-completion or
teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE
association ends on EOF.
@@ -212,7 +212,7 @@ the same channel:
whole point); pump-phase failures are EOF-shaped (alkcall
ADR-049 §6).
- **BAST:** the ASSOCIATE datagram frame is the one binary framing
beyond pass-through — its BAST document is [bast.md](bast.md).
beyond pass-through; its BAST document is [bast.md](bast.md).
## Normative byte diagrams
@@ -227,7 +227,7 @@ UDP ASSOCIATE (the datagram stage, one direction's view):
```
[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ...
zero-length data: an empty datagram (legal payload, NOT EOF —
zero-length data: an empty datagram (legal payload, NOT EOF;
the F-2 invariant at payload level)
len == 0 (no addr bytes): unreachable from a compliant peer
EOF: the channels-level sentinel, outside the codec
@@ -244,9 +244,9 @@ BIND (the phase sequence, one channel's view):
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
None currently open against this document — the datagram framing
None currently open against this document: the datagram framing
(the one-way door) is decided (ADR-003); the phase lifetimes'
timeout posture is decided (ADR-014 — teardown-governed, no
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.
@@ -256,7 +256,7 @@ surface.
- 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-014 (the teardown-governed
phase lifetimes — no protocol-layer idle timeout)
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),
@@ -266,4 +266,4 @@ surface.
(channel open), ADR-049 (establishment/typed errors), ADR-050
(`pump_bidi`); alktunnels ADR-003 (the length codec + F-2)
- RFC 1928 (the protocol's own wire rules), iroh-socks5 eval
(`docs/research/iroh-socks5-eval.md` — the frame prior art)
(`docs/research/iroh-socks5-eval.md`, the frame prior art)
+78 -78
View File
@@ -1,52 +1,52 @@
# Fuzzing alksocks: Research and Plan
**Status:** adopted — wave-1 targets (`datagram_codec`, `rfc1928_codec`,
**Status:** adopted; wave-1 targets (`datagram_codec`, `rfc1928_codec`,
`params_parse`) implemented, seeds committed, campaigns run clean (§7).
Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the
detached runner (§6) — **never as a foreground child of an agent
detached runner (§6): **never as a foreground child of an agent
session** (§5).
**Date:** 2026-09-28
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
(one real finding: the duplicate adopt/open destroying the live channel —
(one real finding: the duplicate adopt/open destroying the live channel,
fixed in 0.8.1) and
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2
adopted, six harness-model findings, no crate bugs). Tooling, corpus
policy, runner, and the no-hosted-CI posture are inherited from those
docs wholesale — this doc does not re-litigate them; it scopes them to
docs wholesale; this doc does not re-litigate them. It scopes them to
this crate.
---
## 1. Should this crate be fuzzed?
**Yes — and alkcall's fuzz doc already assigned this to us.** Three
**Yes, and alkcall's fuzz doc already assigned this to us.** Three
lines, all pointing the same way:
1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream
targets.** Its "non-surfaces (deliberately)" note ends: *the channels
layer carries opaque `Bytes` and the handler owns sub-stream framing
(ADR-035) — those downstream parsers are downstream crates' fuzz
targets.* Our `src/wire.rs` datagram codec **is** that named
(ADR-035); those downstream parsers are downstream crates' fuzz
targets. Our `src/wire.rs` datagram codec **is** that named
downstream target (shaped on alktunnels' codec, which alktunnels
fuzzed as its wave-1 first target — the same assignment, landed
fuzzed as its wave-1 first target; the same assignment, landed
here). Fuzzing it completes the upstream scope rather than
duplicating it.
2. **We own an entire RFC handshake state machine fed by arbitrary
peers — on both sides.** The targeted fork (`src/rfc1928/`, ADR-013)
peers, on both sides.** The targeted fork (`src/rfc1928/`, ADR-013)
is fully owned code with project conventions applying unconditionally
(convention 17). Its parse surfaces — `read_address` (the address
(convention 17). Its parse surfaces, namely `read_address` (the address
codec), `negotiate_auth` (method negotiation), `read_command` (the
command encoding), `read_username_password` (RFC 1929), the client's
`read_request_reply` — are fed bytes produced by **arbitrary peers**
command encoding), `read_username_password` (RFC 1929) and the client's
`read_request_reply`, are fed bytes produced by **arbitrary peers**
in both directions: the producer drives the server typestate against
consumer bytes, and the consumer sessions + front doors drive the
client path against producer bytes. An RFC state machine is exactly
the shape the quinn-proto CVE class (RUSTSEC-2026-0037 — remote DoS
the shape the quinn-proto CVE class (RUSTSEC-2026-0037, remote DoS
via `unwrap()` in parsing, the upstream doc §1's strongest
datapoint) lives in: hand-rolled state transitions, length arithmetic,
and boundary handling. One fork-carried correction is already of this
class — upstream's `ReplyError::from_u8` ended in `unreachable!()`
class: upstream's `ReplyError::from_u8` ended in `unreachable!()`
for unassigned reply codes; the fork made it a typed catch-all (the
review's F-1) precisely because the value comes off the wire. A fuzz
target is how that posture is *demonstrated* across the whole codec,
@@ -54,13 +54,13 @@ lines, all pointing the same way:
3. **The differential suite's blind spot is exactly fuzzing's
strength.** `tests/rfc1928-differential.rs` pins our fork's behavior
against the reference checkout on *valid* RFC edge cases — curated
against the reference checkout on *valid* RFC edge cases, curated
inputs a human thought of. What no example-based suite provides:
random *malformed* input exploration (the panic-on-attacker-bytes
class), stateful interleavings, and resource-bound assertions under
adversarial sequences. The fork is about to be published (0.1) as
wire-stable; after that, targets are regression protection for a
frozen surface — cheapest now.
frozen surface (cheapest now).
**The honest caveat** (mirroring both upstream docs): the crate has no
`unsafe` (checked at the crate root), the codec's decode direction is
@@ -72,7 +72,7 @@ more than 255), and the fork's error vocabulary is typed end-to-end
Expected yield is therefore not memory-safety bugs; it is (a) the
demonstration that the infallibility/no-panic contracts hold on
adversarial input, (b) the decoder-state bound assertion (alktunnels
§1.3's finding class — a peer chunk amplifying into unbounded state),
§1.3's finding class: a peer chunk amplifying into unbounded state),
and (c) regression protection for the published wire surface. If the
campaigns find nothing, that is the successful-negative-result posture
alkcall's §1 codified.
@@ -84,7 +84,7 @@ Identical to both upstream docs: **cargo-fuzz 0.13.2 + `arbitrary`
`fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88.
AFL optional/deferred; bolero deferred (the async-friction argument);
libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address`
with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in
with debug assertions (cargo-fuzz defaults); no MSAN (no `unsafe` in
this crate).
**Layout (the upstream pattern, load-bearing):**
@@ -97,7 +97,7 @@ fuzz/
│ ├── datagram_codec.rs
│ ├── rfc1928_codec.rs
│ └── params_parse.rs
├── shared/ alksocks-fuzz-shared — STABLE-toolchain library
├── shared/ alksocks-fuzz-shared (STABLE-toolchain library)
│ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
@@ -106,16 +106,16 @@ fuzz/
```
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
binaries — the stable-toolchain corpus replay
binaries: the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs, on stable, in the release
verification checklist. This is alkcall §7.2 tier-3's deliverable
minus automation; per alkcall §7.9 (adopted here identically) this
repo has no hosted CI and will not get one — corpus replay as plain
repo has no hosted CI and will not get one. Corpus replay as plain
`cargo test` is the fuzz gate.
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
publish exclude list — without it cargo auto-discovers `fuzz/shared/`
publish exclude list; without it cargo auto-discovers `fuzz/shared/`
into the main workspace and the MSRV toolchain tries to build the
nightly-consumed dev-deps (the exact failure alkcall hit).
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
@@ -129,23 +129,23 @@ Input: raw `&[u8]`. Drives `DatagramReader::feed` + `frame` /
`frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1
`datagram_codec` target's shape, scoped to this crate's codec (the
frame carries `addr + data` inner bytes and decodes to
`(TargetAddr, payload)`, not opaque datagrams — the invariants follow
`(TargetAddr, payload)`, not opaque datagrams; the invariants follow
the inner structure). Invariants:
- **no-panic on any byte sequence** (the baseline).
- **infallible reassembly**: `feed` never errors at the reassembly
layer — every frame completes as `Ok` or as one of the four
layer; every frame completes as `Ok` or as one of the four
*decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` /
`ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are
unreachable from `feed` (they are frame-time errors — a shape
unreachable from `feed` (they are frame-time errors; a shape
assertion pins the partition).
- **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes
only from a real `[len][addr]` frame whose payload region is empty —
only from a real `[len][addr]` frame whose payload region is empty,
never invented by the codec; a `len == 0` frame (zero inner bytes)
always surfaces as `EmptyFrame`, never as an empty datagram.
- **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual
first inner byte of its frame, and it is not one of the three
RFC ATYPs (the post-reply control-byte semantic — any non-ATYP
RFC ATYPs (the post-reply control-byte semantic: any non-ATYP
control byte must fail loud, never guess).
- **consumption accounting / splitting transparency**: feeding in
3-byte slices yields exactly the datagram sequence the whole-input
@@ -155,14 +155,14 @@ the inner structure). Invariants:
well-framed input re-frames byte-for-byte.
- **joint bound**: `frame` rejects iff
`addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing
`(total, max)` exactly — including the max-domain case where a legal
`(total, max)` exactly, including the max-domain case where a legal
RFC payload (65507 to a 255-char domain = 65766) must reject (the
joint-bound reason the codec exists); `frame_inner` rejects iff
`inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload
overflow wrapping the u16) rejects, never wraps.
- **decoder-state bound**: `buf` + in-flight `current` stay bounded by
the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any
adversarial input — the alktunnels §1.3 finding-class assertion
adversarial input, the alktunnels §1.3 finding-class assertion
(state must never grow with input). The crate's `DatagramReader`
exposes this via `is_mid_frame()` + a new `retained_bytes()`
diagnostic (the alktunnels `retained_bytes()` posture).
@@ -175,11 +175,11 @@ batched frames, split frames, garbage ATYP, non-UTF-8 domains, the
### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`)
Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio
duplex streams (the in-tree test shape — mock bytes, no sockets):
duplex streams (the in-tree test shape, mock bytes, no sockets):
- **`read_address`** (the address codec, both directions consume it):
no-panic on any ATYP/bytes; every failure is a typed `AddrError`
(`IncorrectAddressType` for unknown ATYP — the F-1 class, already
(`IncorrectAddressType` for unknown ATYP, the F-1 class, already
fixed; never a panic); round-trip for every well-formed address
(`to_be_bytes` → parse → equal).
- **the server handshake** (`negotiate_auth` → `read_command` over a
@@ -190,7 +190,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
command + target.
- **the client reply path** (`read_request_reply`): no-panic on any
server byte sequence; unassigned reply codes map to the
`GeneralFailure` catch-all (the F-1 pin — never `unreachable!()`);
`GeneralFailure` catch-all (the F-1 pin, never `unreachable!()`);
version mismatches are typed errors.
- **`parse_udp_request`** (the RFC UDP header codec, front doors only
but still wire-reachable): no-panic; garbage RSV / unknown ATYP /
@@ -198,7 +198,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
`new_udp_header`.
- **auth sub-handshakes** (`read_username_password`, RFC 1929):
no-panic; the u8 length fields bound allocations structurally
(≤ 255 bytes — asserted); empty username/password are typed errors;
(≤ 255 bytes, asserted); empty username/password are typed errors;
round-trip accept/reject status bytes.
- **`ReplyError::from_u8`** full-domain table: every `u8` maps, no
panic, unassigned → catch-all (the direct probe of the F-1 class).
@@ -222,16 +222,16 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
- **no-panic on any JSON-shaped input** (`deny_unknown_fields`
dispatch, the registry-spec builder).
- **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` ==
`{}`, and unknown fields always reject (the loud-rejection posture —
`{}`, and unknown fields always reject (the loud-rejection posture:
the serde pin and the schema's `additionalProperties: false` agree).
- **the spec is a constant**: `socks5_open_spec()`'s wire-visible
fields (op id, ALPN, scopes, schemas) are identical on every
execution — the open-op surface is wire-stable (ADR-001); the
execution; the open-op surface is wire-stable (ADR-001); the
reason-enum table equals the typed parse table (the schema/typed-view
drift guard, mirrored from the in-suite test).
- **the reason surface is a pass-through extraction, not a validated
table**: `establishment_reason` yields `Some(details.reason)` iff the
code is `channel:open_failed` and the reason is a string — any string
code is `channel:open_failed` and the reason is a string; any string
is surfaced as-is (the alktunnels harness-model finding #2, adopted
here as the modeled contract); `SocksEstablishmentReason::parse`
never panics and is total.
@@ -239,29 +239,29 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
### 3.4 What is deliberately NOT a target (yet)
- **The producer pump (`serve_socks5_over`) / consumer sessions as
stateful op-sequence targets** — wave 2, deferred (§4). The
stateful op-sequence targets**: wave 2, deferred (§4). The
handshake-level target (§3.2) covers the byte surface; the
session-level interleavings (associate stage select-shape, BIND quiet
wait, the two-pump CONNECT plane) are stateful shapes over a live
channels stack — the alktunnels wave-2 pattern, worth its own session
channels stack (the alktunnels wave-2 pattern), worth its own session
post-0.1.
- **The doors (`door.rs`, `local/`, `composed/`)** — feature-gated
- **The doors (`door.rs`, `local/`, `composed/`)** are feature-gated
assembly; their RFC-codec boundary (`parse_udp_request` /
`new_udp_header`) is covered by the §3.2 target. The rest is I/O
plumbing over the fuzzed cores, not parse surfaces.
- **`front_door` RFC 1929 negotiation** — covered by §3.2's auth
- **`front_door` RFC 1929 negotiation**: covered by §3.2's auth
sub-handshake invariants (the door drives the same forked parsers).
## 4. Scope: two waves
**Wave 1 (this session):** the three targets above. All drive
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching
already-`pub` APIs; no `#[cfg(fuzzing)]` exposure needed (matching
both upstream docs' experience across all eight targets to date). One
diagnostic addition to the crate: `DatagramReader::retained_bytes()`
(the decoder-state byte count §3.1's bound asserts against, in
alktunnels' `retained_bytes()` style).
**Wave 2 (deferred — post-0.1):** `session_pumps`, the stateful
**Wave 2 (deferred to post-0.1):** `session_pumps`, the stateful
op-sequence target over the producer pump + consumer sessions in the
alkcall `manager_routing` / alktunnels `session_pumps` style (live
channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the
@@ -269,7 +269,7 @@ two-pump shutdown-on-completion contract, the egress readiness
contract, and the datagram-stage exit matrix as invariants). Deferred
because wave 1 covers the wire byte surface completely, the session
harness is a large build, and the crate is one commit from its first
publish — the alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption,
publish. The alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption,
wave 2 the same session) was possible there because its session shape
was the crate's whole surface; here the byte-level targets carry the
attacker-reachable load and the stateful session target is a separate
@@ -282,108 +282,108 @@ wave 2 when scheduled.
## 5. Operational isolation: campaigns never share fate with the agent session
**Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels
§5)** — an environment constraint, not a nice-to-have. Agent sessions
§5)**: an environment constraint, not a nice-to-have. Agent sessions
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
as a child of the session's own process tree and cgroup; an OOM in a
fuzz target must cost the fuzzer, never the session. Three layers, all
mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048`
`-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode
`-malloc_limit_mb=2048`); never `ulimit -v` under ASAN; (2) fork mode
(`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only
the short-lived child; the harness survives and persists the artifact;
also the deadlock answer); (3) process detachment — the runner script
also the deadlock answer); (3) process detachment: the runner script
wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a
log file; poll, never `wait`.
Also inherited: the operational note that `cargo fuzz run` returns
before its last fork-mode children fully exit — prefer `pgrep -af` to
before its last fork-mode children fully exit; prefer `pgrep -af` to
LIST, kill by explicit pid from the runner's echoed `pid=`, and never
pattern-kill-and-recheck in one call.
## 6. No hosted CI
Per alkcall §7.9 / alktunnels §6 — the standing policy applies to this
Per alkcall §7.9 / alktunnels §6, the standing policy applies to this
repo identically: no hosted CI will exist; do not add Actions/Gitea
Actions workflow files. **Corpus replay is the fuzz gate as plain
`cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml`
replays every committed seed through the invariant functions — add it
replays every committed seed through the invariant functions, so add it
to the release verification checklist (AGENTS.md). Campaigns run
manually via the detached runner (§6) — e.g. before a release, or after
manually via the detached runner (§6); e.g. before a release, or after
touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
## 7. Implementation log
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets
- **2026-09-28: proposal adopted** (this doc). Wave-1 targets
`datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred
(§4).
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1,
- **2026-09-28: `alkcall` bumped 0.8.0 → 0.8.1,
`alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change;
alkcall's fix (the duplicate adopt/open destroying the live channel)
is directly relevant to this crate's consumer sessions — every
is directly relevant to this crate's consumer sessions: every
session's teardown reaps via the manager, and the
open-op-response-replayed scenario alkcall's changelog names is the
consumer open path. Verification: full suite (default + all-features)
green, clippy, fmt, wasm check (§8 commands).
- **2026-09-28 — wave-1 implemented.** The `fuzz/` workspace laid out
- **2026-09-28: wave-1 implemented.** The `fuzz/` workspace laid out
per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
dict, generator); the three targets over already-`pub` APIs — no
dict, generator); the three targets over already-`pub` APIs, with no
`#[cfg(fuzzing)]` exposure needed (the upstream docs' experience,
now across eleven targets). One diagnostic addition to the crate:
`DatagramReader::retained_bytes()` (§3.1's bound target). Seeds
committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2
campaign-found crash inputs promoted (`datagram_codec/seed-900`,
`seed-901` — the §7 harness findings below).
- **2026-09-28 — two harness-model defects caught by corpus replay
`seed-901`, the §7 harness findings below).
- **2026-09-28: two harness-model defects caught by corpus replay
pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern):
1. **A `len == 0` frame is well-framed but decodes to
`Err(EmptyFrame)`** — the re-frame round-trip first expected every
`Err(EmptyFrame)`**: the re-frame round-trip first expected every
well-framed frame to decode `Ok`. The frame count still holds over
every frame; the byte-for-byte re-frame holds over the decodable
ones; `EmptyFrame` is pinned as the only zero-inner-byte shape.
The same arm widened for `ShortAddress`/`DomainNotUtf8`/
`BadAtyp`: a well-framed frame can carry any addr-garbage shape —
`BadAtyp`: a well-framed frame can carry any addr-garbage shape;
the decode refuses the addr, and the raw frame is the only
re-frame possible.
2. **A fieldless `deny_unknown_fields` struct deserializes from ANY
empty JSON composite** (the serde seq form `[]` included) — not
empty JSON composite** (the serde seq form `[]` included), not
only `{}`. Not wire-reachable (the registry's `type: object`
input schema is the gate that keeps non-object forms from ever
reaching the type), but the harness must model the serde reality:
the trivial-params invariant is now "an empty composite parses,
everything non-empty rejects, the canonical round-trip is `{}`."
- **2026-09-28 — first campaign finding (fork-mode containment as
designed): a third harness-model defect.** The `datagram_codec`
- **2026-09-28: first campaign finding, fork-mode containment as
designed (a third harness-model defect).** The `datagram_codec`
campaign crashed at ~35 s on input `0005 0301 1203 03bb 00`
(`BadAtyp(0)` shape) — the re-frame walk's `Err` arm (fixed in the
(`BadAtyp(0)` shape); the re-frame walk's `Err` arm (fixed in the
replay pass above) still omitted `BadAtyp`. Fixed; the crash input is
committed as `seed-900`; the campaign relaunched clean.
- **2026-09-28 — second campaign finding (also harness, fork-mode
containment): the count walk's `declared >= 7` gate excluded short
domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a
- **2026-09-28: second campaign finding (also harness, fork-mode
containment); the count walk's `declared >= 7` gate excluded short
domains.** Input `0005 0301 1203 03bb 00`, a domain frame with a
1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but
the walk's `declared >= 7` gate (meant to skip addr-less frames)
rejected it; the per-ATYP `addr_len` computation already does that
job. The gate lowered to `>= 4` (the minimum ATYP + length-byte
prefix the walk reads); the crash input is committed as `seed-901`;
the campaign relaunched clean.
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6
- **2026-09-28: wave-1 campaigns run (10-min detached runs, §6
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25`; codec `-max_len=65536`, rfc1928
`-max_len=4096 -use_value_profile=1`, params
`-dict=json.dict -use_value_profile=1). All three exited 0 with
empty artifact directories — no crashes, hangs, OOMs, or leaks.**
- `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s),
empty artifact directories: no crashes, hangs, OOMs, or leaks.**
- `datagram_codec` (post-harness-fixes), ~1.9M execs (~3k exec/s),
33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage
saturated at 481 edges (ft 2222, corp 209) — the 2-byte-header
saturated at 481 edges (ft 2222, corp 209); the 2-byte-header
state machine is fully enumerated (the alktunnels wave-1
saturation profile).
- `rfc1928_codec` — ~2.0M execs (~3.6k exec/s), 33 fork jobs,
- `rfc1928_codec`, ~2.0M execs (~3.6k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429
edges (ft 7987, corp 704) — the handshake state machines'
edges (ft 7987, corp 704); the handshake state machines'
byte-level shape space is fully enumerated; both protocol
directions held across every adversarial sequence.
- `params_parse` — ~3.1M execs (~5.5k exec/s), 33 fork jobs,
- `params_parse`, ~3.1M execs (~5.5k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft
12970, in-memory corpus 2981 from the 19 committed seeds) — the
12970, in-memory corpus 2981 from the 19 committed seeds); the
serde/untagged shape space was still expanding at budget end; the
spec-constant and pass-through-reason invariants held everywhere.
- The decoder-state bound (§3.1) held everywhere:
@@ -392,13 +392,13 @@ touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
committed seeds (the §2 policy).
- **NO crate bugs found by wave 1.** All three findings this session
were harness-model defects, each invisible to example-based review
and caught by fuzzing/replay — the upstream "fuzzer as a
and caught by fuzzing/replay: the upstream "fuzzer as a
harness-checker" pattern, confirmed a third time (alkcall §7.7,
alktunnels §7, here §7). The codec's contracts (infallible
reassembly, the F-2 layering, the joint bound, the retained-bytes
bound) and the RFC core's no-panic posture held across every
adversarial input the campaigns generated.
- (progress appended as implementation proceeds — see git log)
- (progress appended as implementation proceeds; see git log)
## 8. Verification commands
@@ -425,11 +425,11 @@ Campaign flags per target: `datagram_codec -max_len=65536`;
runner, corpus policy, no-CI posture):
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`,
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md`
- alkcall 0.8.1 changelog — the finding our consumer open/adopt paths
- alkcall 0.8.1 changelog, the finding our consumer open/adopt paths
are exposed to
- This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013
(the targeted fork), `docs/architecture/wire.md`,
`docs/architecture/rfc1928.md`
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers'
- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
Fuzz Book (all linked from the upstream doc's §9)
+32 -32
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13
---
# alksocks research — iroh-socks5 as an alternative base
# alksocks research: iroh-socks5 as an alternative base
Evaluation of `iroh-socks5` 0.5.0 (crates.io, published 2026-08-31 by
Matthew Geddes) as a potential base for alksocks, in place of or in
@@ -62,7 +62,7 @@ Module map:
Better than the README's self-deprecation suggests ("LLM-assisted... All
bugs are mine"). The wire codecs are clean, bounded, and unit-tested;
the integration suite (`tests/integration.rs`, 12 tests under
`test-utils`) exercises real iroh connections — direct and via-relay
`test-utils`) exercises real iroh connections (direct and via-relay
dials, CONNECT (IP + domain), refused-target error mapping, full UDP
ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases.
Doc comments are genuinely explanatory. Notable specifics:
@@ -72,29 +72,29 @@ Doc comments are genuinely explanatory. Notable specifics:
serde (not hand-rolled enums) for the inner types.
- **UDP header codec** (`socks5.rs` `parse_udp_packet`/
`format_udp_packet`): correct ATYP handling, uses `get(..)?` bounds
checks, propagates the FRAG byte, domain targets supported — same
checks, propagates the FRAG byte, domain targets supported; same
functional slot as fast-socks5's `new_udp_header`/`parse_udp_request`.
- **Half-close propagation** in `relay_tcp`: each direction signals
`finish()`/`shutdown()` on completion — a hand-rolled two-pump that
`finish()`/`shutdown()` on completion, a hand-rolled two-pump that
*does* shut down the opposite sink (the ADR-050 contract), then joins
both halves (`tokio::join!` + propagate both errors).
- **UDP association lifecycle**: the client side ends the association
when the SOCKS5 control connection closes (`wait_until_closed`
drains-and-detects EOF on the control TCP), not merely when the UDP
socket goes quiet — the RFC's "TCP control connection closes →
socket goes quiet; it implements the RFC's "TCP control connection closes →
association ends" semantic, which many proxies get wrong.
- **Reply-code mapping** is faithful where it matters (refused vs
unreachable distinguishes `ErrorKind::ConnectionRefused`).
Weaknesses (acceptable for its scope, relevant if borrowed):
- **RFC 1929 username/password is absent** — no-auth only
(`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
- **RFC 1929 username/password is absent** (no-auth only:
`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
is the iroh `EndpointId` allow-list at the connection level.
- **BIND's second-incoming-connection shape** accepts exactly one
inbound connection and has no timeout or multi-accept handling (RFC
1928 BIND is notoriously underspecified; this is a reasonable minimal
read, but it binds a real `TcpListener` — the exact thing alksocks'
read, but it binds a real `TcpListener`, the exact thing alksocks'
no-bind principle forbids in the protocol layer).
- **UDP relay is one socket + flow-table-free on the server side**: all
datagrams from the association share one `UdpSocket`, replies are
@@ -102,10 +102,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
back as `TargetAddr::Ip(from)`). Fine for the common case; no
per-endpoint flow table like alknet's `Socks5UdpSocket` model.
- **`allowlist_denies_unknown_peer`** closes the QUIC connection without
an RFC reply — a vanilla client sees a hard close, not a SOCKS5 error
an RFC reply; a vanilla client sees a hard close, not a SOCKS5 error
code. (In alksocks this maps to the typed `channel:open_failed`
establisher refusal, which POC #1 already handles better.)
- Client-side UDP binds a real socket per association (necessarily —
- Client-side UDP binds a real socket per association (necessarily:
it's the front door for vanilla clients); server-side UDP likewise
binds.
@@ -117,7 +117,7 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
+ `Router::accept(ALPN, handler)` is the same posture as alkcall's
`register_openable` (ALPN-scoped produced resource, handler per
connection). iroh 1.0's `ProtocolHandler` is even *single-method*
(`accept(Connection)`) — structurally the open-handler shape POC #1
(`accept(Connection)`), structurally the open-handler shape POC #1
used. The crate's ALPN string convention (`iroh-socks5/1`) matches
the alk/ ALPN convention too.
2. **One stream per session, no sub-demux.** The `Request`/`Reply`
@@ -129,10 +129,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
dials), `Client` = consumer half with the optional local TCP front
door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's
`local_front_door` example validated). The *only* consumer path is
the local bind — there is no in-band (channels-native) client session
the local bind; there is no in-band (channels-native) client session
type, which is where alksocks' wrapper differs.
4. **Target resolution on the producing side.** Domains are carried
unresolved (`TargetAddr::Domain`) and resolved at the exit node —
unresolved (`TargetAddr::Domain`) and resolved at the exit node,
the ssh `-D` semantic phase-0 §OQ-SK-05 notes, with a pluggable
resolver (`DnsResolver`) and a privacy rationale (DoH so the exit
host's network can't observe names) that mirrors the iroh-privacy
@@ -144,13 +144,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
`client.rs` are written against `iroh::endpoint::{Connection,
SendStream, RecvStream}` concretely; `relay.rs` signatures take
`&mut SendStream`. Extracting a substrate-agnostic core would mean
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs) — and
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs); and
those are the parts fast-socks5 already provides in stricter, more
complete form (typestate, auth traits, reply-code enums, generic `T`).
2. **Not generic over the stream.** The whole reason POC #1 could drop a
channels `BiStream` into fast-socks5's `Socks5ServerProtocol<T>` with
zero adaptation is fast-socks5's `T: AsyncRead + AsyncWrite + Unpin`
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam —
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam;
the equivalent refactor would be a rewrite, not a fork.
3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing
path), no auth-method negotiation machinery (fast-socks5's
@@ -158,13 +158,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
(reply codes are bare `u8` consts). fast-socks5 is a conformant
implementation with a test suite covering the RFC edge cases;
iroh-socks5 is a pragmatic subset.
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) — heavy
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) is a heavy
dependency for the wasm-clean default-crate invariant (OQ-SK-04);
the vendored-subset posture would reduce it to the two codec files
anyway, at which point fast-socks5's already-POC'd fork (POC #4:
~120 cfg lines, functional wasm-clean subset) dominates.
5. **No in-band client session type.** The only consumer path is the
local TCP front door — there is no client half that speaks the
local TCP front door; there is no client half that speaks the
framed protocol from inside a process without a kernel listener
(fast-socks5's `Socks5Stream::use_stream` slot). alksocks'
consumer half needs exactly that; iroh-socks5 doesn't offer it.
@@ -172,18 +172,18 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
### The valuable part: OQ-SK-03 evidence
iroh-socks5 implements channels-native UDP ASSOCIATE without binding a
relay-visible port on the producer side beyond the real egress socket —
relay-visible port on the producer side beyond the real egress socket;
the exact question OQ-SK-03 poses. Its answers:
- **Datagram stage rides the same stream** (its "second channel" option
is not used; the association's stream carries framed `Datagram`s
post-reply). Matches the phase-model hunch, including "control stream
goes silent after the reply" — nothing else is ever sent on the
goes silent after the reply": nothing else is ever sent on the
control stream after the ASSOCIATE reply; the client-side relay
monitors the control connection's EOF (`wait_until_closed`) as the
association's end-of-life, and the RFC's "post-reply control data is
garbage" semantic holds.
- **The datagram framing is a length prefix, not the RFC UDP header** —
- **The datagram framing is a length prefix, not the RFC UDP header**:
the RFC UDP header (`RSV/FRAG/ATYP/addr/port`) is applied only at the
*front door* (the local UDP socket a vanilla client sends to), where
it belongs: `relay_udp_client` parses/`format_udp_packet`s at the
@@ -191,22 +191,22 @@ the exact question OQ-SK-03 poses. Its answers:
This is a cleaner cut than phase-0's hunch (which put the length
prefix inside the same stream as RFC headers): the alkcall-channel
wire carries `{addr, data}` postcard frames (boundary-preserving by
construction — an empty datagram is a frame with empty `data`, never
construction (an empty datagram is a frame with empty `data`, never
EOF; alktunnels F-2's requirement is met structurally), and RFC header
encode/decode exists only where a vanilla-client socket boundary
exists (the optional local backends on either side).
- **Server-side `BND.ADDR` semantics**: the ASSOCIATE reply reports the
*real* bound UDP socket address (`udp.local_addr()`) — because in
*real* bound UDP socket address (`udp.local_addr()`), because in
this design the server-side relay socket is real. On alksocks'
channels path (no socket), phase-0's sentinel question remains open;
iroh-socks5 sidesteps it by having the vanilla-facing side be the
*client* half. A producer-side vanilla UDP front door (OQ-SK-03's
local-backend-bridge option) would need the same real-socket
treatment; the wrapper-aware in-band path can carry a sentinel or
(better, per this evidence) no address at all — the framed `Datagram`
(better, per this evidence) no address at all; the framed `Datagram`
stream needs no `BND.ADDR` to function; the address is pure
RFC-compatibility surface.
- **No per-endpoint flow table on the producer side** — the relay
- **No per-endpoint flow table on the producer side**; the relay
socket is one `send_to`/`recv_from` loop with `TargetAddr` carried
per datagram. This is *simpler* than alknet's flow-table model and
sufficient: per-datagram addressing in the frame does the
@@ -224,13 +224,13 @@ the exact question OQ-SK-03 poses. Its answers:
| Stream genericity | `T: AsyncRead + AsyncWrite + Unpin` throughout | concrete iroh `SendStream`/`RecvStream` |
| Server structure | explicit typestate + interception points | single `accept()` method, monolithic |
| Auth extension seam | `AuthMethod` trait | none (allow-list only) |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`) — equivalent |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`), equivalent |
| UDP ASSOCIATE over the transport | default handler binds sockets; custom seam narrow (phase-0 caveat) | clean framed-datagram design, no producer-side virtualization needed |
| Producer dial seam | interception points (`run_tcp_proxy`, command-read typestate) | none (dial hardwired in `handle_connection`) |
| Client session genericity | `Socks5Stream::use_stream` (any `T`) | none (local TCP front door only) |
| wasm posture | fork verified minimal (POC #4) | moot — iroh + tokio-full are native-only |
| wasm posture | fork verified minimal (POC #4) | moot (iroh + tokio-full are native-only) |
| Tests | unit + examples; router/custom-auth examples | strong integration suite over real iroh infra |
| License | MIT (third-party upstream; inline-vendored — OQ-SK-04) | MIT OR Apache-2.0 |
| License | MIT (third-party upstream; inline-vendored, OQ-SK-04) | MIT OR Apache-2.0 |
| LOC | ~4k | ~1.3k |
## Verdict
@@ -240,20 +240,20 @@ stand); **adopt iroh-socks5's UDP design as prior art for OQ-SK-03**:
1. The framed `{addr, data}` datagram stream (no RFC header in-band, RFC
header only at vanilla front doors) is the cleaner expression of the
phase model — the RFC UDP header becomes a front-door codec concern,
phase model: the RFC UDP header becomes a front-door codec concern,
not a channels-wire concern.
2. Per-datagram addressing with no producer-side flow table suffices
for the *local egress* variant (revisit alknet's flow-table posture
in light of this). Caveat added 2026-09-13: under *composed* egress
(per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap
discussion) the flow table returns as a destination→tunnel-channel
table — a dial-callback policy, not a protocol-layer concern.
3. Control-stream EOF ends the association (`wait_until_closed` shape —
table, a dial-callback policy, not a protocol-layer concern.
3. Control-stream EOF ends the association (`wait_until_closed` shape:
on channels, the channel's EOF is the same signal; no extra control
vocabulary needed).
4. FRAG ≠ 0 refused is an acceptable v1 posture.
Attribution if code is reused (the two codec files are the candidates —
Attribution if code is reused (the two codec files are the candidates,
though fast-socks5 already covers them): MIT OR Apache-2.0 dual license,
copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a
provenance note in any vendored file header.
@@ -261,6 +261,6 @@ provenance note in any vendored file header.
Also worth noting (posture corrected 2026-09-20: fast-socks5 is
third-party upstream, so nothing is "filed" there as of right;
iroh-socks5 is upstream's own crate): nothing blocking. Optionally a
repository link on the crates.io entry would help discoverability — the
repository link on the crates.io entry would help discoverability; the
repo exists at `github.com/mattgeddes/iroh-socks5` but `repository` is
null on crates.io.
+256 -253
View File
File diff suppressed because it is too large. Load diff
+62 -62
View File
@@ -1,14 +1,14 @@
---
status: complete
last_updated: 2026-09-20 (ledger statuses updated — the graduation
last_updated: 2026-09-20 (ledger statuses updated with the graduation
landed; both alktunnels asks superseded in shape)
---
# alksocks POC findings — channels-native SOCKS5 BIND
# alksocks POC findings: channels-native SOCKS5 BIND
POC #5 from phase-0 §OQ-SK-07, run 2026-09-15. Crate:
`alksocks-bind-poc` (standalone, `/workspace/alksocks-bind-poc/`), against
alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature —
alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature;
the listen surface is complete in the published version),
fast-socks5 1.0.0 (crates.io). 9 tests, all pass; `cargo clippy
--all-targets -- -D warnings` and `cargo fmt --check` clean.
@@ -19,7 +19,7 @@ exactly one inbound accept → reply#2 with the peer address → raw
pass-through data plane); both accept variants behind the accept
callback; the `socks5:bind` scope gate.** fast-socks5 parses the
command but refuses it (`lib.rs:384-386`, `server.rs:813-816`); the
wrapper drives the typestate directly and owns the whole BIND shape —
wrapper drives the typestate directly and owns the whole BIND shape:
**zero fast-socks5 upstream ask for BIND itself** (the typestate's
`read_command()` returns `(cmd, target)` and the wrapper branches;
`reply_success`'s `sock_addr` parameter is the reply#1 carrier;
@@ -27,24 +27,24 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## What was validated
1. **The BIND phase model — confirmed end to end.** The RFC
1. **The BIND phase model was confirmed end to end.** The RFC
conversation and the data plane ride the same channel `BiStream`:
the typestate runs `accept_no_auth → read_command` (BIND arrives
as `Socks5Command::TCPBind`), reply#1 carries `BND.ADDR`, the
quiet wait selects the one inbound accept against the control
stream, reply#2 carries the peer address, and `pump_bidi`
(inline — R-02) two-pumps the accepted connection against the
channel. Zero per-packet headers in-band (the CONNECT pass-through
conclusion extends literally — BIND's post-reply#2 stage is a
(inline, R-02) two-pumps the accepted connection against the
channel. Zero per-packet headers in-band; the CONNECT pass-through
conclusion extends literally (BIND's post-reply#2 stage is a
plain byte stream).
2. **The local accept variant (variant 1, the faithful RFC shape) —
2. **The local accept variant (variant 1, the faithful RFC shape),
confirmed.** A real `TcpListener` per BIND session: reply#1
carries the listener's *actual* `local_addr` (a real network-B
address); the "app server" dials the advertised address; reply#2
carries the *real* peer address (`accept()`'s peer, byte-exact —
carries the *real* peer address (`accept()`'s peer, byte-exact;
the test asserts the app's ephemeral port); the data plane echoes
both directions. Vanilla front-door clients work (test 6).
3. **The composed accept variant (variant 2) — confirmed, with the
3. **The composed accept variant (variant 2) confirmed, with the
sequencing finding below.** The listener lives on an alktunnels
listen-tunnel resource (`register_tunnel_listen_openable` +
`AcceptQueue` + a real far-side `TcpListener`); the SOCKS5
@@ -54,38 +54,38 @@ wrapper drives the typestate directly and owns the whole BIND shape —
`alk/tunnel` listen channel → tunnel producer → real listener →
the app server's connection → back through the pump. Per-listener
ACL for free (the tunnel resource's ACL governs who can mint the
listener), hub-relayable — the variant's whole point.
4. **The scope gate — confirmed.** The pump handler reads the
listener), hub-relayable, the variant's whole point.
4. **The scope gate is confirmed.** The pump handler reads the
open-op `AuthContext`'s identity and requires `socks5:bind` before
serving a BIND; an identity without it gets the RFC-faithful
`ConnectionNotAllowed` (0x02) refusal — while CONNECT still works
`ConnectionNotAllowed` (0x02) refusal, while CONNECT still works
for the same identity (the gate distinguishes ingress listeners
from proxy egress; one identity having egress does not imply
ingress). The gate is the open-op identity seam consumed at
command-dispatch time — no new ops, no params change, no
command-dispatch time; no new ops, no params change, no
one-way-door exposure.
5. **The quiet wait's semantics — confirmed.** A control byte after
5. **The quiet wait's semantics are confirmed.** A control byte after
reply#1 is protocol garbage (the ASSOCIATE post-reply semantic
BIND shares; RFC 1928 §3's "BIND: no further control data"
reading); the session is refused with no data plane. A client
that BINDs and vanishes (EOF mid-wait) tears the listener down
with its session — the accept future (which owns the listener)
is dropped by the `select!` branch; a fresh dial to the
advertised address fails with connection refused. The session's
natural end (half-close post-data-plane) releases the listener
the same way.
6. **Failure mapping — confirmed RFC-faithful.** A factory failure
with its session; the accept future (which owns the listener)
is dropped by the `select!` branch; a fresh dial to the
advertised address fails with connection refused. The session's
natural end (half-close post-data-plane) releases the listener
the same way.
6. **Failure mapping is confirmed RFC-faithful.** A factory failure
(pre-reply#1: an unbindable port, a held port) maps to reply#1
with the factory error's reply code (shortage → GeneralFailure);
an accept-stage failure (post-reply#1) maps to reply#2 with the
mapped refusal code and no data plane. No phantom listeners, no
phantom data planes; refusals are typed end to end.
7. **One registration, all three commands — regression-guarded.**
7. **One registration, all three commands, regression-guarded.**
CONNECT (POC #1's data plane, unchanged) and UDP ASSOCIATE (the
POC #2 skeleton) still serve on the same `channels/socks5/sub` op
after the BIND branch lands. The RFC 1928 command surface is now
complete in the crate's shape: CONNECT + BIND + ASSOCIATE.
8. **The vanilla front door — confirmed for BIND.** The raw-pipe
8. **The vanilla front door is confirmed for BIND.** The raw-pipe
bridge (POC #1's CONNECT front-door shape, reused verbatim) carries
a full vanilla BIND conversation: an in-process RFC 1928 client
(greeting → BIND command → reply#1 → reply#2 → data plane) through
@@ -96,23 +96,23 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## Structural findings (the load-bearing discoveries)
**The address-first-vs-accept-first deadlock — the POC's one real
**The address-first-vs-accept-first deadlock: the POC's one real
discovery, and the composed variant's sequencing is now pinned.**
SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` *before*
the app server can dial), while alktunnels' listen establisher is
accept-first (its establishment phase pops an already-accepted
connection — the open op blocks until one arrives). The first cut
connection, so the open op blocks until one arrives). The first cut
opened the listen-tunnel in the factory call (before reply#1) and
deadlocked into the establishment timeout: the app server cannot dial
until reply#1 tells it the address, and the open cannot complete until
it dials. **The resolution: reply#1 first, the tunnel open rides the
accept future** — the quiet wait is exactly where the establisher's
accept future.** The quiet wait is exactly where the establisher's
accept-pop block belongs. What remains upstream-blocked is *learning
the bound address without consuming an accept* (ledger ask #1): the
stand-in is the factory's carried `bind_addr` (the expected
`local_addr` the far-side real listener reports).
Consequence for the ADR: variant 2 needs TWO upstream asks, not one —
Consequence for the ADR: variant 2 needs TWO upstream asks, not one:
(1) listen-addr inspection without consuming an accept (the address
half), and (2) accepted handles carrying peer metadata (the reply#2
half). Both are additive surface additions, not changes; the ask
@@ -120,23 +120,23 @@ ledger below records both with the evidence.
**Establishment-bound interaction (the composed variant's wait
bound).** The accept future's tunnel open runs under the listen
establisher's default 10s establishment bound — a BIND wait longer
establisher's default 10s establishment bound; a BIND wait longer
than that resolves as a refusal (reply#2, GeneralFailure via the
mapped open error). Correct-by-backstop, but a real BIND deployment
(FTP control sessions idle far longer than 10s) needs either a raised
bound (the open-op's `timeout` parameter — a registration choice, no
bound (the open-op's `timeout` parameter; a registration choice, no
wire change) or the accept future structured so the bound covers only
the open, not the wait (POC note: the bound covered the whole accept
here; the crate's shape should bound only the open-op round trip).
Phase 1 pins this with the registration posture.
**The teardown-at-birth lesson repeats — and the fix is structural.**
**The teardown-at-birth lesson repeats, and the fix is structural.**
The composed variant's first working cut dropped the
`TakenHalves::session` (the channel-entry owner) while destructuring
it — the tunnel EOF'd at birth and the data plane read `early eof`
it; the tunnel EOF'd at birth and the data plane read `early eof`
(POC #2's findings predicted exactly this: "the session must live
inside the table entry; the *compiler* should enforce it"). The fix:
`BindTargetHandle` carries `_session: Option<TunnelSession>` — the
`BindTargetHandle` carries `_session: Option<TunnelSession>`; the
owner rides the handle and drops with it. This is now the third
witness of the pattern (POC #2's egress table, alktunnels ADR-005's
teardown contract, here); the crate's Phase 1 shape should make the
@@ -144,7 +144,7 @@ plan payload own its channel handles structurally, never by let-binding.
**Peer-address fidelity is lost through the composed path** (ledger
ask #2). The popped accept handle (`alktunnels::TargetHandle`) carries
boxed halves only — the real peer address exists at the tunnel
boxed halves only; the real peer address exists at the tunnel
producer's listener; in-band the composed variant reports the sentinel
(`0.0.0.0:0`) for reply#2 (the handle-swap reading applied to BIND:
the reply is RFC-compatibility surface; the data plane is the
@@ -154,7 +154,7 @@ item #2 is the fidelity ask.
**The quiet wait is `select!`-single-shot, not a loop** (unlike
ASSOCIATE's stage): the accept resolves at most once, and the control
stream's first byte ends the session (garbage). A loop was
unnecessary — clippy's `never_loop` caught the first cut's vestigial
unnecessary; clippy's `never_loop` caught the first cut's vestigial
`loop`; the phase model's clean statement is that BIND's quiet wait
has exactly two exits (the accept, or the control stream's
EOF/garbage/error). The data plane starts only from the accept
@@ -164,48 +164,48 @@ branch.
the egress's readiness contract.** The accept future owns the
listener (local variant) or the tunnel-open + popped halves (composed
variant); the quiet wait's `select!` drops it on the control-stream
branch — that drop IS the client-gone teardown. The crate's Phase 1
branch, and that drop IS the client-gone teardown. The crate's Phase 1
equivalent: the accept callback's future must own its resources
end-to-end (the same ownership lesson as POC #2's egress, mirrored).
## Wire/API notes for the wrapper
- fast-socks5's client `request()` reads exactly one reply — BIND's
- fast-socks5's client `request()` reads exactly one reply: BIND's
reply#2 is hand-read with fast-socks5's own public
`read_address` codec (reply#1's codec applied again; no second
codec hand-rolled). The consumer's reply#2 reader (`read_reply2`)
is the Phase 1 client-session sketch.
- `Socks5ServerProtocol::reply_success(sock_addr)` is reply#1's
carrier — the `sock_addr` parameter carries `BND.ADDR` verbatim
carrier: the `sock_addr` parameter carries `BND.ADDR` verbatim
(the local variant's real listener; the composed variant's
expected far-side address). No upstream change needed.
- fast-socks5's `new_reply` helper is private; the BIND reply#2
writer hand-rolls the 10-byte reply (VER, REP, RSV, ATYP, addr,
port — 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy):
port; 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy):
publish `new_reply` (or a `reply(code, addr)` constructor) so
embedders don't re-encode replies. (2026-09-20: rides the inline
vendor — see §ledger statuses.)
vendor; see §ledger statuses.)
- `ToTargetAddr::to_target_addr()` (not `From`) is the
`SocketAddr → TargetAddr` conversion — `TargetAddr::from` does not
`SocketAddr → TargetAddr` conversion; `TargetAddr::from` does not
exist (the POC hit this; the wrapper's accept layer will too).
- The listen establisher's default 10s bound covers the composed
variant's whole accept future in this POC's shape — see the
variant's whole accept future in this POC's shape; see the
establishment-bound finding above for the Phase 1 posture.
- The scope gate is read from the `OpenHandler`'s `AuthContext`
(CF-005/CF-006's per-call opener identity) — the same seam POC #1
(CF-005/CF-006's per-call opener identity), the same seam POC #1
validated for session-gating, now consumed for a *command-class*
distinction. `socks5:bind` as a scope string follows the
`socks5:open` convention (Phase 1 pins the exact convention).
## Upstream-ask ledger (the one-version alktunnels bump)
**Ledger statuses (2026-09-20 — the graduation landed).** The user's
**Ledger statuses (2026-09-20, the graduation landed).** The user's
standing note held: alktunnels 0.2.0 (ADRs 007/008, over alkcall
0.8.0) shipped in one version bump — but adopted the ssh
0.8.0) shipped in one version bump, but adopted the ssh
`forwarded-tcpip`-shaped alternative over the per-ask carriers, so
both asks are **superseded in shape**, not implemented as posed:
1. **Listen-addr inspection without consuming an accept — dissolved.**
1. **Listen-addr inspection without consuming an accept: dissolved.**
The bind-first establisher (`bindfirst_establisher`, ADR-008 §1)
binds during establishment, so the open-op reply natively carries
the real OS-chosen bound address (the `bound` reply field via
@@ -214,24 +214,24 @@ both asks are **superseded in shape**, not implemented as posed:
`TunnelListener::bound()`). There is no accept to avoid consuming;
this ask's stand-in (the factory's carried `bind_addr`) is retired,
as is the POC's "open rides the accept future" sequencing
workaround — and the 10s establishment-bound finding dissolves with
workaround, and the 10s establishment-bound finding dissolves with
it (the accept wait lives in the pump phase now). The
accept-first surface (`listen_establisher` + `AcceptQueue`) remains
in 0.2.0 unchanged for the accept-as-open shape.
2. **Accepted handles carrying peer metadata — landed at the wire's
2. **Accepted handles carrying peer metadata: landed at the wire's
native moment.** The forwarded op (`channels/tunnel/forwarded`,
ADR-008 §3) carries `{listen, substrate, peer}` params per accept;
`TargetHandle` gained no peer field — the graduation chose the
`TargetHandle` gained no peer field; the graduation chose the
forwarded-op shape over this item's option-i carrier. Correlation
is a producer-minted u32 token (Amendment 1, 2026-09-18), not the
originally-planned channel-ID key.
3. **(fast-socks5, courtesy)** `new_reply` is private — publish it
3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
(or a constructor) so embedders don't re-encode the reply wire
form. Also still open from POC #4: the `net` feature gate and the
`run_udp_proxy_custom` unconditional-bind seam. (Posture note,
2026-09-20: fast-socks5 is third-party upstream and these are no
longer asks — all three move into the inline vendor, OQ-SK-04
resolved. 2026-09-21: under the extraction — ADR-011 §Amendment —
longer asks; all three move into the inline vendor, OQ-SK-04
resolved. 2026-09-21: under the extraction (ADR-011 §Amendment),
`new_reply` is the one that rides in-tree; the `net` gate and the
bind seam dissolve with the kernel-net region, which is not
copied.)
@@ -242,7 +242,7 @@ alktunnels 0.1.0; the Phase 1 shape composes variant 2 against 0.2.0
The user's standing note: collect alktunnels additions so one version
bump ships them all. The POC ran against published alktunnels 0.1.0
(no fork needed — the listen surface is complete there), and the asks
(no fork needed; the listen surface is complete there), and the asks
are additive surface, discovered in priority order:
1. **Listen-addr inspection without consuming an accept** (the
@@ -259,13 +259,13 @@ are additive surface, discovered in priority order:
the composed accept's reply#2 reports the sentinel; the RFC's
"reply#2 carries the address of the incoming connection" is
unfilled on the composed path. Shape: `TargetHandle` gaining an
optional `peer: Option<SocketAddr>` (or a sibling
`AcceptedHandle`) — additive, no wire change.
3. **(fast-socks5, courtesy)** `new_reply` is private — publish it
optional `peer: Option<SocketAddr>` (or a sibling
`AcceptedHandle`); additive, no wire change.
3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
(or a constructor) so embedders don't re-encode the reply wire
form. Also still open from POC #4: the `net` feature gate and the
`run_udp_proxy_custom` unconditional-bind seam. (2026-09-21: under
the extraction — ADR-011 §Amendment — `new_reply` is the one that
the extraction, ADR-011 §Amendment, `new_reply` is the one that
rides in-tree; the `net` gate and the bind seam dissolve with the
kernel-net region, which is not copied.)
@@ -275,7 +275,7 @@ reply#2) are honest interim shapes.
## OQ status after this POC
- **OQ-SK-08 (BIND)**: POC-validated end to end — the phase model
- **OQ-SK-08 (BIND)**: POC-validated end to end: the phase model
extends (reply#1 → quiet wait → accept → reply#2 → pass-through);
both accept variants implemented (local faithful + composed
listen-tunnel); the scope gate (`socks5:bind`) distinguishes
@@ -283,20 +283,20 @@ reply#2) are honest interim shapes.
*resolved in shape* (reply#1 first, the open rides the accept
future) with the two upstream asks pinned as the remaining blockers
for variant 2's full fidelity. Remaining for Phase 1: ship-in-base-
crate + default-accept-variant ADR (the support is additive — old
consumers still get the RFC refusal — so nothing here is a
crate + default-accept-variant ADR (the support is additive, old
consumers still get the RFC refusal, so nothing here is a
one-way door); the establishment-bound posture; the exact
scope-string convention.
- **OQ-SK-07**: #1, #2, #4, #5 complete; #3 dropped (transferred with
OQ-SK-06). The register is closed for this crate's Phase 0.
- **OQ-SK-01 (dial policy)**: the accept seam is the dial-callback
shape's mirror (`BindAcceptFactory` — function-not-trait, boxed
shape's mirror (`BindAcceptFactory`: function-not-trait, boxed
halves, one instance per session). The no-trait precedent now holds
for the accept plane too.
## POC placement
Standalone crate (global workspace, `/workspace/alksocks-bind-poc/`)
per the placement conventions — it composes three published crates
per the placement conventions; it composes three published crates
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
from this repo. Findings land here; the crate is the artifact.
+34 -34
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13
---
# alksocks POC findings — channels-native CONNECT + fast-socks5 wasm posture
# alksocks POC findings: channels-native CONNECT + fast-socks5 wasm posture
POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
@@ -13,37 +13,37 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
fast-socks5 1.0.0 (crates.io). 8 tests + 1 example
(`local_front_door`, the curl front door). All pass.
- **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc`
(scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` — not
part of this repo; the finding is the artifact). fast-socks5's
(scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` (not
part of this repo; the finding is the artifact)). fast-socks5's
`src/` vendored, kernel-net pieces gated behind a `net` feature.
## POC #1 — channels-native SOCKS5 CONNECT
## POC #1: channels-native SOCKS5 CONNECT
### What was validated
1. **`BiStream`-as-`T` genericity — confirmed.** The channels
1. **`BiStream`-as-`T` genericity confirmed.** The channels
`BiStream` (`alkcall::core::types`, `Box<dyn AsyncReadWrite +
Unpin>`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`)
satisfies fast-socks5's `Socks5ServerProtocol<T>` server typestate
and `Socks5Stream<S>::use_stream` client path directly. No adapter,
no wrapper stream, no boxing beyond what `BiStream` already is.
2. **`register_openable_with_establisher` fit — confirmed.** The
2. **`register_openable_with_establisher` fit confirmed.** The
producer registers `channels/socks5/sub` (ALPN `alk/socks5`, scope
`socks5:open`) with an establisher + pump handler exactly as
alktunnels does for `channels/tunnel/sub`.
3. **`pump_bidi` as the CONNECT data plane — confirmed.** Post-reply
3. **`pump_bidi` as the CONNECT data plane confirmed.** Post-reply
the handler awaits `pump_bidi(channel_bistream, target_read,
target_write)` inline; echo round-trips clean through
client → channel → pump → target → back.
4. **Session refusal is a typed call error — confirmed.** An identity
4. **Session refusal is a typed call error, confirmed.** An identity
lacking the open scope gets `FORBIDDEN` at the registry ACL gate;
`ChannelOpenError::CallFailed` carries it; no phantom channel.
The establisher's per-call identity arrives as expected
(witnessed `"consumer"`; CF-005/CF-006 seam works).
5. **Dial failure → RFC reply code mapping — confirmed.** The dial
5. **Dial failure → RFC reply code mapping confirmed.** The dial
refusal surfaces to the RFC client as `ConnectionRefused` (0x05)
in the CONNECT reply — the faithful per-RFC error mapping
AGENTS.md convention 2 requires — with no phantom data plane.
in the CONNECT reply, the faithful per-RFC error mapping
AGENTS.md convention 2 requires, with no phantom data plane.
6. **The front door works with a vanilla client (stretch goal).**
The consumer-side local exposure shape: bind a real TCP listener,
pipe each accepted connection raw onto a channel `BiStream`
@@ -56,13 +56,13 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
### Structural findings (the load-bearing discovery)
**The dial cannot live in the establisher — and that is correct, not
**The dial cannot live in the establisher, and that is correct, not
a limitation.** The SOCKS5 *target* is unknown at open time: RFC 1928
method negotiation and the CONNECT command (with its target address)
arrive in-stream, after channel establishment. The layering the POC
validates:
- **The establisher is a session gate** — it validates/records the
- **The establisher is a session gate**: it validates/records the
opener identity (OQ-SK-02's seam) and does session-level policy. It
never sees a target.
- **The dial belongs at the command-read interception point**, inside
@@ -81,32 +81,32 @@ natural dialer return type. The no-trait hunch is now evidence-backed.
**Identity propagation (CF-005 (b)) has a producer-side step the
harness had to do:** the accept-side install hook must call
`channel0_conn.set_identity(resolved)` before building the
`CallConnection` — the transport identity does not flow to the
`CallConnection`; the transport identity does not flow to the
channel-0 dispatch automatically. (alktunnels' harness does the same;
modeled mTLS posture. A real mTLS transport would resolve the peer
here.) Worth an upstream note: without it, scope-gated opens fail
closed with `FORBIDDEN "authentication required"` — correct behavior,
closed with `FORBIDDEN "authentication required"`, correct behavior,
but the wiring requirement is implicit.
### Wire/API notes for the wrapper
- `TargetAddr::Ip(addr).to_string()` is `ip:port` — when handing the
- `TargetAddr::Ip(addr).to_string()` is `ip:port`; when handing the
dial closure a structured target, use `addr.ip().to_string()` +
`addr.port()` separately (the POC hit this; the wrapper's target
carrier will too).
- `Socks5Stream::request(cmd, TargetAddr)` — the target must be
- `Socks5Stream::request(cmd, TargetAddr)` requires that the target be
constructed via `ToTargetAddr`; IP-string forms parse to IP targets
(domain form passes through unresolved, per OQ-SK-05's DNS question).
- `SocksServerError::AuthenticationRejected` is a unit variant (no
message field) in 1.0.0.
- `Establishment` with no plan (`Establishment::default()`) is the
right shape for SOCKS5 — the dial happens post-establishment, so
right shape for SOCKS5; the dial happens post-establishment, so
there is nothing to thread establisher→handler.
- The open-op params are trivially empty (`{}`); the spec's input
schema is `{type: object}`. Params stay empty unless a later ADR
adds per-session options.
## POC #4 — fast-socks5 wasm fork-minimality
## POC #4: fast-socks5 wasm fork-minimality
### Baseline
@@ -117,7 +117,7 @@ but the wiring requirement is implicit.
OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep
chain pulls `mio`/`socket2`.)
### The gating change — measured, not guessed
### The gating change, measured not guessed
Vendored fast-socks5's `src/` into a scratch crate; feature-gated
every kernel-net touch behind a `net` feature (default-on, exactly the
@@ -133,20 +133,20 @@ alktty `local` pattern):
| `Cargo.toml` | `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, default-on | tokio stays `default-features = false, io-util/time/sync/macros` |
Diff size vs upstream: **~120 changed lines across 5 files, all
`#[cfg]` insertions** (client.rs 87 diff lines — inflated by the
`#[cfg]` insertions** (client.rs 87 diff lines, inflated by the
whitespace of moving the datagram block; server.rs 27; the rest ≤3).
### Results
- `cargo check --no-default-features` (native): clean.
- `cargo check --target wasm32-unknown-unknown --no-default-features`:
**clean** — no `mio`, no `socket2`, no `tokio::net` in the graph.
**clean**: no `mio`, no `socket2`, no `tokio::net` in the graph.
- `cargo test --no-default-features`: a full RFC 1928 CONNECT
conversation (typestate server over a duplex ↔ `use_stream` client)
passes — the wasm-clean subset is the *functional* protocol subset,
passes; the wasm-clean subset is the *functional* protocol subset,
not a husk.
- `cargo check --target wasm32-unknown-unknown` with `net` on:
fails in `mio` (expected — the target gate for wasm users is
fails in `mio` (expected; the target gate for wasm users is
`default-features = false`).
### Verdict for OQ-SK-04
@@ -156,24 +156,24 @@ whitespace of moving the datagram block; server.rs 27; the rest ≤3).
insertions and zero code rewrites; the wasm-clean subset still
implements the whole RFC conversation. tokio itself needs
`default-features = false` with an explicit `net` opt-in feature
(`net = ["tokio/net", ...]`) — that is the entire shape of the change.
(`net = ["tokio/net", ...]`); that is the entire shape of the change.
The one structural discovery: **`tokio_stream` (used for the legacy
`Incoming` stream) and `socket2` must be optional deps keyed to the
same feature**, and `try_join!` requires tokio's `macros` feature —
same feature**, and `try_join!` requires tokio's `macros` feature;
all mechanical.
Remaining for Case 1: the fork posture is vendoring *into this crate*
(per phase-0: vendor the `T`-generic subset, gate the rest), keeping
`/workspace/fast-socks5` as the differential reference; the upstream
PR (feature-gate `net` behind a default-on feature) is small enough
to offer as-is. **(2026-09-20: resolved — the inline vendor is the
to offer as-is. **(2026-09-20: resolved; the inline vendor is the
decision; the PR is an optional courtesy, never a wait. See phase-0
OQ-SK-04.)**
### Follow-on changes (posture corrected 2026-09-20: not upstream asks)
**Ownership correction:** fast-socks5 is third-party upstream — we do
**Ownership correction:** fast-socks5 is third-party upstream; we do
not own it, and it has not been forked. These are therefore not asks;
they are changes the inline vendor carries in-tree (OQ-SK-04, resolved
2026-09-20). The wasm-gate change below is POC #4's; the other two
@@ -182,7 +182,7 @@ into the vendor with it.
1. **Feature-gate kernel-net behind a default-on `net` feature**
(POC #4's change, ~120 lines of `#[cfg]`). (2026-09-21: dissolved
under the extraction — ADR-011 §Amendment; the kernel-net region
under the extraction (ADR-011 §Amendment); the kernel-net region
is not copied, and POC #4's gate map survives as the extraction
recipe, not in-tree cfg.)
2. **`run_udp_proxy_custom` binds a kernel socket unconditionally**
@@ -190,24 +190,24 @@ into the vendor with it.
customizable seam covers the relay closure and reply IP only; the
channels path must drive the typestate directly. Make the
`udp_bind_random_port` step optional / accept a caller-supplied
socket. (Both #2 and the POC #5 courtesy item — publish `new_reply`
— ride the vendor; see `poc-bind-findings.md` §ledger statuses.)
socket. (Both #2 and the POC #5 courtesy item, publish `new_reply`,
ride the vendor; see `poc-bind-findings.md` §ledger statuses.)
## OQ status after these POCs
- **OQ-SK-01 (dial policy)**: shape resolved empirically — an injected
- **OQ-SK-01 (dial policy)**: shape resolved empirically: an injected
dial callback invoked at the command-read interception point;
boxed-halves return type; no trait (alktunnels' no-trait precedent
holds). Where the callback *comes from* (alktunnels composition vs
local TCP vs further hops) stays an assembly-layer question.
- **OQ-SK-02 (auth mapping)**: the seam works — per-call opener
- **OQ-SK-02 (auth mapping)**: the seam works; per-call opener
identity arrives at the establisher, and channel-0 identity
propagation is a wiring requirement the producer harness performs.
In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing
username/password path for the local backend remains Phase 1.
- **OQ-SK-04 (wasm)**: fork-minimality verified; the decision reduced
to "does wasm make sense for alksocks" (the adapter story), with
the mechanism de-risked. **(2026-09-20: resolved — inline vendor,
the mechanism de-risked. **(2026-09-20: resolved, inline vendor,
on the corrected ownership premise + the BIND/seam needs. See
phase-0 OQ-SK-04.)**
- **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq)
+36 -36
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-14
---
# alksocks POC findings — channels-native UDP ASSOCIATE
# alksocks POC findings: channels-native UDP ASSOCIATE
POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate:
`alksocks-udp-poc` (standalone, `/workspace/alksocks-udp-poc/`), against
@@ -20,47 +20,47 @@ composed variant's per-destination open cost is measured.
## What was validated
1. **The handle-swap data plane — confirmed end to end.** The
1. **The handle-swap data plane was confirmed end to end.** The
ASSOCIATE conversation and the datagram stage ride the same
channel `BiStream`. The producer drives the typestate directly
(`accept_no_auth → read_command → reply_success(sentinel)`) —
(`accept_no_auth → read_command → reply_success(sentinel)`);
`run_udp_proxy_custom` is not used (its unconditional
`udp_bind_random_port` makes it unusable on channels, per the
corrected §Prior art caveat; driving the typestate directly is the
workaround the upstream ask would retire).
2. **The in-band frame codec — confirmed.** The wire is alktunnels'
boundary-preserving length codec (`frame_datagram`/`DatagramReader`
— empty payload legal, never EOF; frames split/batch across channel
2. **The in-band frame codec is confirmed.** The wire is alktunnels'
boundary-preserving length codec (`frame_datagram`/`DatagramReader`;
empty payload legal, never EOF; frames split/batch across channel
chunks transparently) over the RFC address encoding
(`TargetAddr::to_be_bytes()` — ATYP + addr + port). RSV/FRAG do not
(`TargetAddr::to_be_bytes()`: ATYP + addr + port). RSV/FRAG do not
ride the tunnel wire: fragmentation cannot even be expressed
in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture). The RFC-header codecs
(`new_udp_header`/`parse_udp_request`) are used only at the
front-door boundary, exactly as the baseline specifies.
3. **Per-datagram addressing, no flow table — confirmed.** One relay
3. **Per-datagram addressing without a flow table was confirmed.** One relay
loop per association; each frame carries its destination; the
local-egress variant relays through one real `UdpSocket` with
per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five
in-order datagrams round-trip over one association
(`udp_multiple_datagrams_one_association`).
4. **Association lifetime = channel lifetime — confirmed.** The
4. **Association lifetime = channel lifetime, confirmed.** The
consumer's `shutdown()` half-closes the stream; the mux's
implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read
as EOF; the stage exits and the connection stays healthy (a fresh
associate succeeds immediately after). No extra control vocabulary
needed — the channels-level EOF is the association's end-of-life
signal, as the iroh-socks5 eval predicted.
5. **Control silence is structurally enforced — confirmed.** The
as EOF; the stage exits and the connection stays healthy (a fresh
associate succeeds immediately after). No extra control vocabulary
needed; the channels-level EOF is the association's end-of-life
signal, as the iroh-socks5 eval predicted.
5. **Control silence is structurally enforced, confirmed.** The
frame's first byte is ATYP, so any post-reply control byte parses
as a garbage ATYP and fails the association loud
(`udp_undecodable_frame_ends_association`, with the unassigned ATYP
0x7F). The phase model needs no explicit mode byte: the reply
itself synchronizes the mode switch.
6. **Both egress variants — implemented and passing.**
6. **Both egress variants: implemented and passing.**
- **Local egress** (`LocalEgress`): one real UDP socket per
association; per-datagram addressing; no flow table. Domain-form
destinations resolve at the egress (producer-side DNS — the ssh
destinations resolve at the egress (producer-side DNS, the ssh
`-D` semantic, POC-resolved as "the egress owns resolution").
- **Composed egress** (`ComposedEgress`): a lazy
destination→alktunnels-udp-tunnel table (each new destination
@@ -80,7 +80,7 @@ composed variant's per-destination open cost is measured.
destination fan-out; the wire space itself is `u32`. The LRU cap +
the dedup assertion (`open_count == 1` for repeated same-resource
destinations) are pinned by `udp_composed_egress_lazy_table_lru_and_open_cost`.
8. **The front door — RFC codec composition at the vanilla boundary,
8. **The front door: RFC codec composition at the vanilla boundary,
confirmed.** An in-process bridge exposes the association on a real
UDP socket; the door's pump decodes RFC-header'd datagrams
(`parse_udp_request`), sends `{addr, data}` frames in-band, and
@@ -91,14 +91,14 @@ composed variant's per-destination open cost is measured.
shape (the CONNECT front door in POC #1 already validated that
pipe shape); this test pins the UDP-specific claim: RFC codec at
the door, frames in-band, zero payload translation.
9. **CONNECT on the same registration — regression-guarded.** One ALPN
9. **CONNECT on the same registration, regression-guarded.** One ALPN
(`alk/socks5`), one op (`channels/socks5/sub`), trivial params; the
CONNECT path is the POC #1 data plane unchanged
(`connect_still_served_on_the_same_registration`).
10. **The identity-seam wiring requirement re-confirmed** for the
composed path: the tunnel producer's channel-0 accept hook must
set the opener identity (`set_identity`), or scope-gated tunnel
opens fail closed with `FORBIDDEN "authentication required"` —
opens fail closed with `FORBIDDEN "authentication required"`;
the same POC #1 finding, now witnessed from the consumer side of
the composed stack.
@@ -107,22 +107,22 @@ composed variant's per-destination open cost is measured.
**The datagram stage is `select!`-driven, and the egress reply
future's readiness contract is load-bearing.** The stage runs channel
reads and the egress's reply stream concurrently (`tokio::select!`).
Three readiness hazards were hit and fixed in this POC — all of them
Three readiness hazards were hit and fixed in this POC, all of them
generalizable API-design notes for the crate's Phase 1:
1. **The poll-then-block window loses replies.** The first iteration
interleaved "drain egress replies (non-blocking), then block on the
channel read" — the echo's reply arriving during the channel block
channel read"; the echo's reply arriving during the channel block
was never picked up. The fix is the concurrent select shape, which
is also the honest full-duplex shape (an egress may produce
unsolicited inbound datagrams at any time).
2. **An instantly-ready `Ok(None)` starves the other branch with
`biased`.** A `next_reply` that returns `Ok(None)` immediately
(drained) makes the biased select take it every iteration — a
(drained) makes the biased select take it every iteration, a
livelock that starves the mux/demux on a current-thread runtime.
The egress contract is therefore: *`next_reply` must be `Pending`
when no reply is ready* (`Ok(None)` is a spurious wakeup, not a hot
loop). The `LocalEgress` parks on `readable()` — note `writable()`
loop). The `LocalEgress` parks on `readable()`; note `writable()`
is the wrong readiness for UDP (always ready, the send-readiness,
spins forever); `readable()` is the datagram-arrival readiness.
The `ComposedEgress` parks on the shared inbound queue's `recv()`;
@@ -131,7 +131,7 @@ generalizable API-design notes for the crate's Phase 1:
3. **The length framing is applied exactly once, by the wire layer.**
`Egress::next_reply` returns the *inner* bytes (`addr + data`);
the producer's `write_frame` adds the single length prefix. (The
first iteration framed in the egress and prefixed in the producer —
first iteration framed in the egress and prefixed in the producer,
double framing; the client parsed the length field as ATYP 0x00.)
**Double framing is the one wire bug the POC caught**, and it
@@ -139,7 +139,7 @@ localizes a Phase 1 API decision: the egress (dial-callback) layer
speaks *inner* bytes; the length codec is the wire layer's. The
consumer side mirrors it: `UdpAssociation::send_datagram` frames once,
its `DatagramReader` state (mid-frame position + batched-datagram
pending queue) persists across `recv_datagram` calls — a datagram
pending queue) persists across `recv_datagram` calls; a datagram
split across channel chunks survives a caller-driven receive loop
(the first cut dropped the reader state per call; the echo test's
2-byte-prefix chunks exposed it).
@@ -150,7 +150,7 @@ drops it (composed tunnels' write halves EOF their channels; the
adopted channel entries are reaped by the sessions' `Drop`, alktunnels
ADR-005). One structural bug here: the first cut bound the opened
session to a local `let _session = ...` binding, which dropped at the
end of the insert block — tearing the channel down at birth (the
end of the insert block, tearing the channel down at birth (the
relay task saw EOF instantly). The session must live inside the table
entry. The crate's Phase 1 equivalent: the egress owns its tunnels
end-to-end, and the *compiler* should enforce it (no `_` bindings).
@@ -158,13 +158,13 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## Wire/API notes for the wrapper
- `TargetAddr::to_be_bytes()` is the in-band address encoding (ATYP +
addr + port) — the RFC UDP header minus RSV/FRAG. `unframe` decodes
addr + port), the RFC UDP header minus RSV/FRAG. `unframe` decodes
it by hand (synchronously over a borrowed slice);
`fast_socks5::util::target_addr::read_address` is the async in-stream
equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are
the Phase 1 codec's direct sketch.
- `alktunnels::wire::frame_datagram`/`DatagramReader` are the length
codec, reused unchanged — the shared framing story with
codec, reused unchanged, the shared framing story with
alktunnels' udp substrate (per the user's codec decision: depend on
alktunnels, do not hand-roll).
- The ASSOCIATE reply's `BND.ADDR` is the sentinel `127.0.0.1:0` on
@@ -173,14 +173,14 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
(`relay_addr`) and never uses it for addressing. Only the front
door has a real address to report.
- `LocalEgress::next_reply` frames replies with the *sender's* address
(the RFC's per-datagram source semantics, `recv_from`'s `from`) —
(the RFC's per-datagram source semantics, `recv_from`'s `from`),
not the original destination. For echo targets the two coincide;
a real producer would attribute by egress socket (the composed
variant's table entry carries the destination explicitly — its
variant's table entry carries the destination explicitly; its
replies are attributed correctly by construction).
- The composed egress's per-destination attribution: the table's
`reply_addr` is recorded at tunnel-open time (the datagram's
original destination) — the tunnel IS the per-destination
original destination); the tunnel IS the per-destination
association, the eval's noted advantage of the composed shape.
- `parse_udp_request` is async (though it completes immediately over a
borrowed slice); a sync mirror (`unframe`) is used where the decode
@@ -191,19 +191,19 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## OQ status after this POC
- **OQ-SK-03 (UDP ASSOCIATE)**: the handle-swap baseline is
POC-validated end to end — one channel per association, `{addr,
POC-validated end to end: one channel per association, `{addr,
data}` frames in-band (RSV/FRAG only at front doors), channel EOF =
association lifetime, per-datagram addressing without a producer
flow table. The datagram framing is wire-stable once published —
flow table. The datagram framing is wire-stable once published, and
the Phase 1 ADR has empirical ground under it. The demux-vs-phase
question (the one-way-door extensibility argument) stays an ADR
decision for Phase 1 with the first consumer in sight, but the POC
adds evidence for the phase model: no in-band control vocabulary was
needed, and post-reply garbage is refused structurally.
- **OQ-SK-01 (dial policy)**: the egress seam is the dial-callback
shape extended to the datagram stage (`EgressFactory` — the `DialFn`
shape extended to the datagram stage (`EgressFactory`, the `DialFn`
analogue). Both egress variants behind the callback, the protocol
layer sees neither — the no-trait, boxed-halves precedent now holds
layer sees neither; the no-trait, boxed-halves precedent now holds
for the datagram plane too.
- **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL-
for-free claim is validated (each destination rides its own tunnel
@@ -214,6 +214,6 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## POC placement
Standalone crate (global workspace, `/workspace/alksocks-udp-poc/`)
per the placement conventions — it composes three published crates
per the placement conventions; it composes three published crates
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
from this repo. Findings land here; the crate is the artifact.
+31 -31
View File
@@ -18,7 +18,7 @@ leverages:
4. **Task-Driven**: Structured task graphs with dependency analysis
5. **Safe Exit**: Always have a way to unblock progress when stuck
6. **Categorical Estimates**: Use risk/scope/impact categories, not time
estimates. These are structurally important — upstream failures multiply
estimates. These are structurally important: upstream failures multiply
downstream damage regardless of developer type (human or LLM). See the
cost-benefit framework in taskgraph's framework docs.
@@ -47,11 +47,11 @@ approaches
**Process**:
1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md` — package purpose, exports, dependencies
- `docs/architecture/<component>.md` — one focused doc per component/area
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs
- `docs/architecture/README.md` (index with doc table, ADR table, lifecycle)
- `docs/architecture/overview.md` (package purpose, exports, dependencies)
- `docs/architecture/<component>.md` (one focused doc per component/area)
- `docs/architecture/decisions/` (numbered ADR files: ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` (centralized tracker with OQ-IDs)
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions).
3. Architecture Review validates for ambiguities, risks, and structural issues
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
- Extract decisions into numbered ADRs in `decisions/` directory
- Centralize open questions in `open-questions.md` with OQ-IDs
- Maintain `README.md` as the architecture index
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them
- Keep spec documents focused (~500 lines); reference ADRs and OQs, don't inline them
- Redirect exploration work to Research Specialist
- Iterate based on review feedback
**Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents
- `docs/architecture/decisions/` — numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md`: centralized OQ tracker
---
@@ -179,17 +179,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions)
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator
Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
sessions get the full operation set, spawned implementation sessions get a
limited set (current, notify, status). No mode toggle required.
**Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort,
- `worktree({action, args})`, which dispatches spawn, sessions, dashboard, message, abort,
cleanup
- Bash (opencode CLI for session interaction)
- Read (monitor task files)
- `memory` / `memory_compact` — context management and session history (via
- `memory` / `memory_compact` for context management and session history (via
@alkdev/open-memory, when available)
**Key Behaviors**:
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**:
- Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment
- `worktree({action: "notify", ...})` to report progress/blockers to coordinator
- `worktree({action: "current"})` to verify worktree assignment
- webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory,
- `memory` / `memory_compact` for context management (via @alkdev/open-memory,
when available)
**Key Behaviors**:
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates
These fields are structurally important, not optional metadata. They power
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that
reveal structural problems in the task graph. A task missing `scope`, `risk`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the
cost-benefit framework in taskgraph's framework docs for the reasoning.
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
### Architecture work and the `level` field
Architecture work uses the same taskgraph format as implementation work —
Architecture work uses the same taskgraph format as implementation work;
there is no separate `architecture` level. The `level` enum
(`planning, decomposition, implementation, review, research`) already covers
architecture activities:
| Architecture activity | `level` | Rationale |
|---|---|---|
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. |
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit (breaking a large architecture area into decideable units). |
| Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. |
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
2. **The machine-readable half** (in `tasks/architecture/`): an
external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the
`level: research` (it is not actionable work; it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's
`Blocked on:` text. This is the surface for the task graph: `taskgraph`
tools can reason about it, and downstream work that depends on the
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
specced), the tracker task is marked `completed` and the OQ transitions from
`deferred(scope)` to `open` (or directly to `resolved` if no architecture
decision remains). The two halves serve different audiences and use one edge
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not
type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
add a `blocks:` field; it duplicates the edge and creates a sync hazard.
## Review Injection
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate
plugin. It's a single tool with `{action, args}` dispatch; there are no separate
enable/toggle steps. Role is auto-detected from session state.
```
@@ -563,13 +563,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the
coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent
- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
sessions spawned from the coordinator
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned
- `memory({tool: "messages", args: {sessionId: "..."}})` to read a spawned
session's conversation for debugging
- `memory({tool: "context"})` — check context window usage before long
- `memory({tool: "context"})` to check context window usage before long
monitoring sessions
- `memory_compact()` — proactively compact at natural breakpoints
- `memory_compact()` to compact proactively at natural breakpoints
Implementation agents can also use `memory({tool: "context"})` and
`memory_compact()` to manage their context during long tasks.
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
```
State moves from in-process tracking to Postgres `mappings` table. The
open-coordinator plugin becomes unnecessary — the hub provides the same
open-coordinator plugin becomes unnecessary; the hub provides the same
capabilities as server-side operations accessible from any environment.
## Document Structure
@@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs)
```
Numbering starts at 001 within each project. ADRs are stable — once Accepted,
Numbering starts at 001 within each project. ADRs are stable: once Accepted,
they don't revert. If superseded, mark the old one and create a new one.
### Open Questions Format
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
- **Status**: open | resolved | deferred(scope) | partially resolved
- **Priority**: high | medium | low
- **Resolution**: (when resolved)
- **Blocked on**: (when deferred — concrete dependency that would unblock)
- **Blocked on**: (when deferred; the concrete dependency that would unblock)
- **Cross-references**: OQ-NN, ADR-NNN
```
+6 -6
View File
@@ -5,19 +5,19 @@ libFuzzer targets for alksocks' wire surfaces (see
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/`: the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
the shared crate is stable-clean.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
- `corpus/<target>/`: committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored.
- `run-detached.sh` — mandatory runner for agent sessions: wraps
- `run-detached.sh`: mandatory runner for agent sessions; wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (research doc §5).
- `json.dict` — JSON token dictionary for the params target.
- `json.dict`: JSON token dictionary for the params target.
## Targets
@@ -31,7 +31,7 @@ libFuzzer targets for alksocks' wire surfaces (see
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself.
nightly toolchain; the detached runner handles that itself.
```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
+1 -1
View File
@@ -81,7 +81,7 @@ def datagram_codec_seeds():
# The joint-bound max-domain shape: a legal RFC payload that would
# overflow the u16 (65507 to a 255-char domain = 65766). The frame
# itself can never carry that length field — encode the closest
# itself can never carry that length field, so encode the closest
# wire-legal shape (0xFFFF declared with the max-domain body) for
# the decode walk; the encode-side 65766 rejection is pinned in
# the shared invariants.
+1 -1
View File
@@ -1,4 +1,4 @@
//! `Arbitrary` support for `serde_json::Value` — a bounded-depth
//! `Arbitrary` support for `serde_json::Value`: a bounded-depth
//! recursive generator shared by the semantic targets. Depth and size
//! bounds keep exec/s high (serde_json's own 128-depth recursion limit
//! is the decode-side guard; this generator explores structure).
+11 -11
View File
@@ -35,7 +35,7 @@ fn probe_addr() -> TargetAddr {
pub fn fuzz_datagram_codec(data: &[u8]) {
// The infallible reassembly direction: any chunk sequence feeds
// cleanly. Drive the reader with the whole input and with awkward
// 3-byte slices — splitting transparency must hold for both.
// 3-byte slices; splitting transparency must hold for both.
let mut whole = DatagramReader::new();
let whole_dgs = whole.feed(data);
assert_retained_bound(&whole, data.len());
@@ -117,7 +117,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// Re-frame round-trip: for input that is a concatenation of
// complete frames, parsing then re-framing reproduces the frames
// byte-for-byte — except a `len == 0` frame, which is well-framed
// byte-for-byte, except for a `len == 0` frame, which is well-framed
// but decodes to `Err(EmptyFrame)` (protocol garbage; it has no
// addr to re-frame). The frame count holds over every frame; the
// byte-for-byte re-frame holds over the decodable ones.
@@ -136,7 +136,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
}
Err(WireError::EmptyFrame) => {
// The frame-walk must agree this is a `len == 0`
// frame — the only zero-inner-byte shape.
// frame: the only zero-inner-byte shape.
let declared = u16::from_be_bytes([frame_bytes[0], frame_bytes[1]]) as usize;
assert_eq!(declared, 0);
assert_eq!(frame_bytes.len(), 2);
@@ -147,7 +147,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// A well-framed frame can decode to the address
// garbage shapes: the declared length carried a
// too-short, non-UTF-8, or unknown-ATYP addr
// region. The decode refused the addr — the only
// region. The decode refused the addr, so the only
// re-frame possible is the raw frame itself, which
// the count/prefix walks above already pin.
}
@@ -159,7 +159,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
}
// Frame direction: the joint bound exactly at the edge, with the
// sizes echoed — including the 65766 max-domain shape (a legal RFC
// sizes echoed, including the 65766 max-domain shape (a legal RFC
// payload that would overflow the u16 length field).
let max_payload = MAX_INNER_LEN - addr_encoding_len(&probe_addr());
let framed = frame(&probe_addr(), &vec![0u8; max_payload]).expect("exact limit is legal");
@@ -214,7 +214,7 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
// Mid-frame observability: after the final feed, the reader is
// mid-frame exactly when the input ends inside a frame (a partial
// frame beyond the 2-byte header; a dangling 1-byte header
// fragment is NOT mid-frame — the header is not yet consumed).
// fragment is NOT mid-frame: the header is not yet consumed).
let expected_mid = ends_inside_frame(data, well_framed_len);
assert_eq!(
whole.is_mid_frame(),
@@ -236,7 +236,7 @@ fn result_shapes(dgs: &[Result<(TargetAddr, bytes::Bytes), WireError>]) -> Vec<S
.collect()
}
/// A cheap digest stand-in: length + first/last bytes — enough to pin
/// A cheap digest stand-in: length + first/last bytes, enough to pin
/// equality across the split feeds.
fn digest(payload: &[u8]) -> u64 {
let mut h = payload.len() as u64;
@@ -251,7 +251,7 @@ fn digest(payload: &[u8]) -> u64 {
/// Walk the framing exactly as the decoder does: complete frames from
/// the start; the walk stops at the first frame that overruns the
/// input. Returns the frames and the byte length they occupy — the
/// input. Returns the frames and the byte length they occupy: the
/// remainder (if any) is a partial/trailing region.
fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
let mut frames = Vec::new();
@@ -269,12 +269,12 @@ fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
}
/// Count complete, well-formed-address frames whose payload region is
/// empty — the only source an empty-payload datagram may come from
/// empty: the only source an empty-payload datagram may come from
/// (the F-2 walk, alktunnels' count_empty_frames shape adapted to the
/// `(addr, payload)` inner structure: a frame's empty payload needs
/// `declared == addr_len`, not `declared == 0`). Domain-form frames
/// must also carry a valid-UTF-8 domain: the codec validates UTF-8
/// before the payload split — a non-UTF-8 domain with an empty payload
/// before the payload split, so a non-UTF-8 domain with an empty payload
/// region surfaces as `DomainNotUtf8`, never as an empty datagram.
fn count_empty_payload_frames(data: &[u8]) -> usize {
let mut count = 0usize;
@@ -315,7 +315,7 @@ fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool {
// Mid-frame iff the well-framed prefix leaves a *parsed but
// incomplete* frame: a remainder ≥ 2 bytes means the length header
// was consumed and the frame is in flight (a remainder of 1 byte
// is a buffered header fragment — not yet a frame in flight).
// is a buffered header fragment, not yet a frame in flight).
let remainder = data.len() - well_framed_len;
remainder >= 2
}
+5 -5
View File
@@ -31,12 +31,12 @@ pub fn fuzz_params_parse(data: &[u8]) {
}
fn parse_shapes(value: &Value) {
// The trivial params: the canonical form is `{}` — it parses and
// The trivial params: the canonical form is `{}`; it parses and
// round-trips to the canonical form. Unknown fields reject loudly
// (the serde pin and the schema's additionalProperties agree —
// (the serde pin and the schema's additionalProperties agree;
// ADR-001). NOTE the serde reality the harness must model: a
// fieldless struct deserializes from ANY empty JSON composite
// (the seq form `[]` included) — the wire's `type: object` input
// (the seq form `[]` included), but the wire's `type: object` input
// schema (the registry's gate) is what keeps the non-object forms
// from ever reaching the type; the serde pin is the second gate.
let parses = serde_json::from_value::<Socks5Params>(value.clone()).is_ok();
@@ -54,7 +54,7 @@ fn parse_shapes(value: &Value) {
assert_ne!(
value,
&serde_json::json!({}),
"the empty object must parse — anything else must reject"
"the empty object must parse; anything else must reject"
);
}
@@ -102,7 +102,7 @@ fn parse_shapes(value: &Value) {
// The reason surface is a pass-through extraction, not a validated
// table: Some(details.reason) iff the code is channel:open_failed
// and the reason is a string — any string surfaced as-is (the
// and the reason is a string; any string is surfaced as-is (the
// alktunnels harness-model finding #2, the modeled contract).
let code = value
.get("code")
+4 -4
View File
@@ -1,7 +1,7 @@
//! Invariants for the forked RFC 1928 core (`src/rfc1928/`, ADR-013 —
//! Invariants for the forked RFC 1928 core (`src/rfc1928/`; ADR-013:
//! fully owned code): no-panic on any byte sequence across the whole
//! handshake state machine, every malformed shape is a typed error
//! (the F-1 class — wire bytes must never reach `unreachable!()`),
//! (the F-1 class: wire bytes must never reach `unreachable!()`),
//! round-trips for well-formed messages, and the full-domain
//! reply-code table. Both protocol directions are covered: the server
//! typestate (the producer drives it against consumer bytes) and the
@@ -80,7 +80,7 @@ async fn fuzz_read_address(data: &[u8]) {
}
}
Err(err) => {
// Typed failure — fine; the ATYP gate maps unknown
// Typed failure: fine; the ATYP gate maps unknown
// types to IncorrectAddressType (the F-1 class).
if let AddrError::IncorrectAddressType = err {
assert!(
@@ -191,7 +191,7 @@ async fn fuzz_udp_header(data: &[u8]) {
/// `ReplyError::from_u8` over the full u8 domain: total, no panic,
/// unassigned codes map to the GeneralFailure catch-all (the F-1
/// pin — the direct probe of the class the fork already fixed).
/// pin, the direct probe of the class the fork already fixed).
fn fuzz_reply_table(data: &[u8]) {
for &code in data {
let reply = ReplyError::from_u8(code);
+143 -134
View File
@@ -1,36 +1,37 @@
//! The `composed` feature's egress + accept providers (ADR-008's
//! composed variants, ADR-009's feature gate): [`direct_egress`] — the
//! composed variants, ADR-009's feature gate): [`direct_egress`], the
//! UDP ASSOCIATE egress that dials each destination through an
//! alktunnels 0.2.0 direct-op channel
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`) —
//! and [`bindfirst_accept`] — the TCP BIND accept whose listener lives
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`);
//! and [`bindfirst_accept`], the TCP BIND accept whose listener lives
//! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's
//! composed accept).
//!
//! **Per-target ACL for free** (ADR-008's composed benefit): each
//! destination rides its own `channels/tunnel/direct`-governed channel
//! — the producer's `tunnel:direct` scope gate and dial policy apply
//! per destination, no SOCKS5-layer allowlist needed (ADR-006/ADR-012:
//! whatever ACL governs the tunnel capability governs everything
//! reachable through it).
//! destination rides its own `channels/tunnel/direct`-governed channel,
//! so the producer's `tunnel:direct` scope gate and dial policy apply
//! per destination, with no SOCKS5-layer allowlist needed
//! (ADR-006/ADR-012: whatever ACL governs the tunnel capability
//! governs everything reachable through it).
//!
//! The shape is POC #2's `ComposedEgress`
//! (`/workspace/alksocks-udp-poc/src/egress.rs`), ported to the landed
//! seam types: a lazy destination→tunnel table — each new destination
//! opens one UDP-tunnel channel, a relay task per tunnel drains
//! inbound datagrams into a shared queue, the write half stays in the
//! table (LRU-capped; dropping a write half EOFs the tunnel
//! producer-side; the entry's session drop reaps the channel —
//! alktunnels ADR-005's teardown contract).
//! seam types: a lazy destination→tunnel table, where each new
//! destination opens one UDP-tunnel channel, a relay task per tunnel
//! drains inbound datagrams into a shared queue, the write half stays
//! in the table (LRU-capped; dropping a write half EOFs the tunnel
//! producer-side; the entry's session drop reaps the channel, which
//! is alktunnels ADR-005's teardown contract).
//!
//! Contracts (the landed seam types' normative docs, honored here):
//!
//! - **The readiness contract** (load-bearing, `Egress::next_reply`):
//! parks on the shared inbound queue (`recv().await` — `Pending`
//! until a relay task delivers); a closed queue (all relays ended)
//! returns `Err` — never a hot `Ok(None)`. The stage's zero-yield
//! defense (review 002 R-1) is the backstop, not the design.
//! - **Framing-once**: replies carry *inner* bytes (`addr + data` —
//! parks on the shared inbound queue (`recv().await`, hence
//! `Pending` until a relay task delivers); a closed queue (all
//! relays ended) returns `Err`, never a hot `Ok(None)`. The stage's
//! zero-yield defense (review 002 R-1) is the backstop, not the
//! design.
//! - **Framing-once**: replies carry *inner* bytes (`addr + data`;
//! the reply address is the entry's recorded destination, the RFC's
//! per-datagram source semantics); the length prefix is the wire
//! layer's, applied exactly once downstream. `send` takes the
@@ -39,34 +40,34 @@
//! wire's.
//! - **Per-datagram `send` is awaited inline** (the stage
//! interleaves sends with reply drains): a slow egress applies
//! backpressure to the association, not to other channels — the
//! backpressure to the association, not to other channels, and the
//! batch-2 composed-egress author inherits this bound knowingly
//! (wire.md's note). The table's open cost rides the send path
//! (lazy open on first datagram per destination — the POC's
//! (lazy open on first datagram per destination; the POC's
//! measured ~0.8 ms).
//! - **The resource/target mapping**: `open_direct` takes a
//! [`SubstrateAddr`] — the destination's [`TargetAddr`] renders to
//! it (IP forms direct; domain form passes through unresolved —
//! [`SubstrateAddr`], which the destination's [`TargetAddr`] renders
//! to (IP forms direct; domain form passes through unresolved,
//! producer-side resolution, the ssh `-D` semantic; ADR-012).
//!
//! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs
//! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle`
//! halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, but
//! alktunnels' `TakenHalves` yields `Send + Unpin` only — and
//! alktunnels' `TakenHalves` yields `Send + Unpin` only; and
//! `alkcall::BiStream` is `Send + Unpin` but NOT `Sync` (its inner is
//! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture
//! (egress.rs's documented contract: "keep-alive state a substrate
//! needs rides inside the halves — a provider-side owner wrapper")
//! takes two shapes here: [`SessionOwner`] (the generic half-wraps-
//! session wrapper — the egress's design point, exported as the
//! wrapper-aware deployment's surface) and the accept's own
//! needs rides inside the halves, a provider-side owner wrapper")
//! takes two shapes here: [`SessionOwner`] (the generic
//! half-wraps-session wrapper, the egress's design point, exported as
//! the wrapper-aware deployment's surface) and the accept's own
//! [`SyncHalves`]/[`OwnedWrite`] adapter pair (a `std::sync::Mutex`
//! around the half — `Mutex<T: Send>` is `Sync`, no unsafe — with
//! around the half; `Mutex<T: Send>` is `Sync`, no unsafe; with
//! the channel session owned inside the payload, [`ForwardedOwner`]:
//! dropping the handle drops the session AND the halves together).
//! For the egress the wrapper is not the handle payload's burden —
//! the egress owns the session in the table entry (the POC's shape);
//! the relay task owns the bare read half.
//! For the egress the wrapper is not the handle payload's burden,
//! because the egress owns the session in the table entry (the POC's
//! shape);
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -91,14 +92,14 @@ use crate::egress::{
pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::wire::MAX_INNER_LEN;
/// The default LRU cap (the 256-channel default cap bounds fan-out —
/// The default LRU cap (the 256-channel default cap bounds fan-out,
/// ADR-010's posture; the cap is config, not wire).
pub const DEFAULT_MAX_TUNNELS: usize = 256;
/// The destination's render into the direct op's [`SubstrateAddr`] (the
/// resource/target mapping): IP forms direct; domain form passes
/// through unresolved (producer-side resolution, the ssh `-D`
/// semantic — ADR-012).
/// semantic, per ADR-012).
pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
match addr {
TargetAddr::Ip(sa) => SubstrateAddr::HostPort {
@@ -114,7 +115,7 @@ pub fn substrate_addr_of(addr: &TargetAddr) -> SubstrateAddr {
/// Render the destination to the table's key form (the string the LRU
/// orders by): `host:port` per [`SubstrateAddr`]'s render (IP literal
/// or unresolved domain — the domain stays unresolved, so domain and
/// or unresolved domain; the domain stays unresolved, so domain and
/// IP destinations key distinctly, as the client addressed them).
pub fn key_of(addr: &TargetAddr) -> String {
match substrate_addr_of(addr) {
@@ -124,19 +125,20 @@ pub fn key_of(addr: &TargetAddr) -> String {
}
/// The owner wrapper (the batch-1 review's seam contract vs
/// alktunnels' surface — the owner-wrapper posture's generic shape;
/// alktunnels' surface, providing the owner-wrapper posture's generic
/// shape;
/// the composed accept meets the `+ Sync` bound with its own
/// [`SyncHalves`]/[`OwnedWrite`] adapter pair instead): an alktunnels
/// half wrapped with its [`TunnelSession`] inside. The landed handle
/// payloads carry `+ Sync` (the
/// `ChannelPlan` payload bound), alktunnels' `TakenHalves` yields
/// `Send + Unpin` only — the wrapper owns the session beside the half,
/// `Send + Unpin` only; the wrapper owns the session beside the half,
/// so dropping the wrapper drops the half and the session together
/// (the session's drop reaps the channel entry — alktunnels ADR-005).
/// Whether the wrapper is `Sync` (the accept's need) is the half's
/// own `Sync`ness — asserted by the compile-shape test against the
/// alktunnels 0.2.0 surface; the egress needs only `Send` (the relay
/// task), which the wrapper always has.
/// (the session's drop reaps the channel entry, per alktunnels
/// ADR-005). Whether the wrapper is `Sync` (the accept's need) is the
/// half's own `Sync`ness, asserted by the compile-shape test against
/// the alktunnels 0.2.0 surface; the egress needs only `Send` (the
/// relay task), which the wrapper always has.
pub struct SessionOwner<H> {
half: H,
session: TunnelSession,
@@ -187,17 +189,17 @@ pub struct ComposedEgress {
order: std::collections::VecDeque<(String, TargetAddr)>,
inbound: tokio::sync::mpsc::Receiver<Inbound>,
inbound_tx: tokio::sync::mpsc::Sender<Inbound>,
/// Live relay tasks — the readiness contract's closed-queue
/// Live relay tasks, hence the readiness contract's closed-queue
/// condition: when every relay has ended (each reported via
/// [`Inbound::Ended`]) and the queue is drained, `next_reply`
/// returns `Err` (the association end), never a hot `Ok(None)`.
/// The factory `Sender` alone does not close the queue (the
/// egress holds it) — the count is the design.
/// egress holds it), so the count is the design.
live_relays: usize,
/// Whether any tunnel has ever been opened. The lazy table starts
/// empty: a fresh association's `next_reply` parks until the first
/// `send` opens a tunnel (the producer's stage selects the channel
/// branch first on a quiescent association — the association
/// branch first on a quiescent association, and the association
/// serves until a datagram arrives or the channel EOFs). The
/// ended-relay `Err` fires only once at least one tunnel existed
/// and every relay has ended (the closed-queue condition).
@@ -205,7 +207,7 @@ pub struct ComposedEgress {
}
/// The shared inbound queue's message: a tunnel's datagram (keyed by
/// the table key — the reply address rides the entry) or a relay's
/// the table key, the reply address riding the entry) or a relay's
/// end marker (the tunnel's EOF drained).
#[derive(Debug)]
enum Inbound {
@@ -214,11 +216,11 @@ enum Inbound {
}
/// One table entry: the tunnel's write half (the udp substrate's
/// framed half — `[len: u16 BE]` frames on the tunnel wire) + the
/// framed half, `[len: u16 BE]` frames on the tunnel wire) + the
/// reply address to report for datagrams coming back through it + the
/// session (the channel-entry owner — the egress owns it, not a
/// session (the channel-entry owner; the egress owns it, not a
/// handle payload; dropping the entry EOFs the write half and reaps
/// the channel — alktunnels ADR-005).
/// the channel, per alktunnels ADR-005).
struct TableEntry {
_session: TunnelSession,
write: Box<dyn AsyncWrite + Send + Unpin>,
@@ -229,7 +231,7 @@ struct TableEntry {
/// into the egress's shared inbound queue. Exits on EOF (the tunnel
/// ended); the write half stays in the table until eviction or stage
/// exit. Framing decode is the tunnel codec's (the alktunnels
/// ADR-003) — the datagrams here are inner payloads (no RFC address
/// ADR-003), so the datagrams here are inner payloads (no RFC address
/// on the tunnel wire; the reply address is the entry's recorded
/// destination).
async fn relay_read(
@@ -255,7 +257,7 @@ async fn relay_read(
let _ = tx.send(Inbound::Ended).await;
}
/// Assemble the reply's inner bytes (`addr + data` — the entry's
/// Assemble the reply's inner bytes (`addr + data`, the entry's
/// recorded destination + the payload; framing-once: no length prefix
/// here, the wire layer's `frame_inner` adds it downstream). The
/// oversize guard mirrors the wire layer's frame bound.
@@ -276,10 +278,10 @@ fn inner_bytes(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, EgressError>
impl ComposedEgress {
/// Build the egress (the per-association ASSOCIATE seam object).
/// `client` is the channels client the direct ops ride; the
/// constructor's config carries the LRU cap (`max_tunnels` — the
/// constructor's config carries the LRU cap (`max_tunnels`, the
/// fan-out bound, config not wire; the producer's `tunnel:direct`
/// ACL governs which destinations the channels accept — the
/// per-target-ACL-for-free benefit).
/// ACL governs which destinations the channels accept, which is
/// the per-target-ACL-for-free benefit).
pub fn new(client: Arc<ChannelClient>, max_tunnels: usize) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(64);
Self {
@@ -294,7 +296,7 @@ impl ComposedEgress {
}
}
/// The entry for `addr` — open lazily on first datagram (the
/// The entry for `addr`, opened lazily on first datagram (the
/// open cost rides the send path, per-datagram awaited inline).
async fn entry_for(&mut self, addr: &TargetAddr) -> Result<&mut TableEntry, EgressError> {
let key = key_of(addr);
@@ -330,7 +332,7 @@ impl ComposedEgress {
self.order.push_back((key.clone(), addr.clone()));
// The entry was inserted above (or the key was already
// present): an absent entry here is a structural invariant
// break, not a runtime condition — surfacing it as an
// break, not a runtime condition; it is surfaced as an
// egress error instead of a panic (convention 2).
self.entries.get_mut(&key).ok_or_else(|| {
EgressError::General(format!(
@@ -366,7 +368,7 @@ impl Egress for ComposedEgress {
// A non-blocking first pass over the shared queue (the
// relay tasks may have queued replies already), then a
// real async wait on the queue (`Pending` until a
// relay task delivers — the readiness contract).
// relay task delivers; this is the readiness contract).
loop {
match self.inbound.try_recv() {
Ok(Inbound::Datagram(key, dg)) => {
@@ -376,8 +378,8 @@ impl Egress for ComposedEgress {
};
return Ok(Some(inner_bytes(&addr, &dg)?));
}
// A relay ended (its tunnel's EOF drained) —
// count it; the closed-queue condition is
// A relay ended (its tunnel's EOF drained);
// count it, and the closed-queue condition is
// every relay ended.
Ok(Inbound::Ended) => {
self.live_relays = self.live_relays.saturating_sub(1);
@@ -391,9 +393,10 @@ impl Egress for ComposedEgress {
// Every relay ended (the tunnels ended): a closed
// relay set must NOT spin the stage's select (an
// instantly-ready None would starve the channel
// branch) — end the association instead. A fresh
// association (no tunnel ever opened) parks here —
// the channel branch governs until the first
// branch), so the stage ends the association
// instead. A fresh association (no tunnel ever
// opened) parks here, and the channel branch
// governs until the first
// datagram arrives.
return Err(EgressError::General("all tunnel relays ended".into()));
}
@@ -405,7 +408,7 @@ impl Egress for ComposedEgress {
};
return Ok(Some(inner_bytes(&addr, &dg)?));
}
// A relay ended while parked — loop back: the
// A relay ended while parked; loop back, since the
// count check above decides whether the
// association ends (never a hot `Ok(None)`).
Some(Inbound::Ended) => {
@@ -425,8 +428,8 @@ impl Egress for ComposedEgress {
/// producer.md §Wiring): the `EgressFactory` the producer
/// registration consumes. One per ASSOCIATE; the stage owns the
/// returned egress; stage exit drops it (write halves dropped = EOF
/// to the tunnel producers; the entries' sessions reap the channels —
/// alktunnels ADR-005's teardown contract).
/// to the tunnel producers; the entries' sessions reap the channels,
/// per alktunnels ADR-005's teardown contract).
///
/// # Backpressure (inherited knowingly)
///
@@ -450,15 +453,16 @@ pub fn direct_egress(client: Arc<ChannelClient>, max_tunnels: usize) -> EgressFa
/// channel's `BiStream` + the forwarded op's `peer` param). One entry
/// per live BIND session; the forwarded handler (the provider's shared
/// opener half) removes and completes the entry on correlation. A
/// dropped receiver (client-gone teardown) makes completion fail — the
/// handler drops the channel stream (teardown-at-birth).
/// dropped receiver (client-gone teardown) makes completion fail, so
/// the handler drops the channel stream (teardown-at-birth).
pub type CorrelationRegistry =
Arc<tokio::sync::Mutex<HashMap<u32, oneshot::Sender<ForwardedSession>>>>;
/// What a correlated forwarded session delivers: the forwarded
/// channel's `BiStream` (raw pass-through — the SOCKS5 BIND data plane
/// is raw on both ends) + the forwarded op's `peer` param (reply#2's
/// real peer — the graduation's win). Public because the
/// channel's `BiStream` (raw pass-through, since the SOCKS5 BIND data
/// plane is raw on both ends) + the forwarded
/// op's `peer` param (reply#2's real peer, the graduation's win).
/// Public because the
/// [`CorrelationRegistry`] it travels through is public API surface
/// (the assembly holds the handle [`register_forwarded_serving`]
/// returns); constructing one is the forwarded handler's job alone.
@@ -470,19 +474,21 @@ pub struct ForwardedSession {
/// The forwarded-op serving handler (the provider's shared opener
/// half): correlates the arriving forwarded open by `listen` token and
/// OWNS the data-plane pump (R-02 — the `register_openable` wrapper
/// OWNS the data-plane pump (R-02: the `register_openable` wrapper
/// awaits this task inline, then tears the forwarded channel down; a
/// handler that returns early reaps the channel at birth). The pump
/// bridges the forwarded channel's `BiStream` to a local duplex pair:
/// the near end pumps here (this task lives exactly as long as the
/// data plane — either end's EOF ends it), the far end travels through
/// data plane, so either end's EOF ends it), the far end travels
/// through
/// the token's oneshot into the accept future's [`BindTargetHandle`]
/// payloads (dropping the handle drops the far end → the pump sees EOF
/// → the task returns → the wrapper reaps the forwarded channel —
/// teardown-at-birth, structural). A correlation miss (no waiting
/// session — client gone, or a token nobody minted) drops the stream:
/// payloads (dropping the handle drops the far end → the pump sees
/// EOF → the task returns → the wrapper reaps the forwarded channel,
/// i.e. teardown-at-birth, structural). A correlation miss (no
/// waiting
/// session (client gone, or a token nobody minted) drops the stream:
/// the task returns immediately, the same reaping shape. A dead
/// oneshot receiver makes the send fail — the task drops the stream
/// oneshot receiver makes the send fail, and the task drops the stream
/// and returns (same reaping).
fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler {
Arc::new(move |forwarded: TunnelForwardedParams, bidi: BiStream| {
@@ -518,15 +524,15 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
}
// The middle pump (three-party half-close would cycle:
// pump_bidi's per-direction shutdown-on-completion
// deadlocks a bridge — the SOCKS5 pump's far.write shutdown
// deadlocks a bridge: the SOCKS5 pump's far.write shutdown
// needs near.read EOF; the wire's bidi.read EOF needs the
// producer pump's far.read EOF; far.read needs near.write
// shutdown; near.write needs bidi.read EOF). The bridge
// therefore completes on EITHER direction's EOF and drops
// everything: the far halves (the SOCKS5 pump sees EOF),
// the near halves, and the BiStream (the forwarded channel
// tears down; the wrapper's inline await returns — R-02 —
// and reaps the entry).
// tears down; the wrapper's inline await returns, per
// R-02, and reaps the entry).
let (near_read, near_write) = tokio::io::split(near);
pump_bidi(bidi, near_read, near_write).await;
})
@@ -535,16 +541,16 @@ fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::Forwarde
/// The forwarded channel's `BiStream` reassembled after the
/// payload-split: the read half rides the [`SyncHalves`] adapter (the
/// mutex-joined form), the write half rides [`OwnedWrite`] — and the
/// mutex-joined form), the write half rides [`OwnedWrite`], and the
/// owner rides inside the write payload so dropping the handle drops
/// everything: the teardown-at-birth discipline, compiler-enforced.
/// The owner is the [`TunnelListener`] (NOT an adopted session — the
/// forwarded channel's entry is the producer's, adopted inside the
/// The owner is the [`TunnelListener`] (NOT an adopted session, since
/// the forwarded channel's entry is the producer's, adopted inside the
/// producer's `ForwardedOpener`): the listener's keep-alive holds the
/// far accept loop (and every per-accept pump it spawned — R-02)
/// far accept loop (and every per-accept pump it spawned; R-02)
/// alive; dropping the handle drops the listener → the listen channel
/// EOFs → the far teardown cascades. The edition-2021 disjoint-capture
/// trap (an earlier draft took `listener.channel_id` — a `u32` — and
/// trap (an earlier draft took `listener.channel_id`, a `u32`, and
/// dropped the listener at the factory's return, EOF-ing the listen
/// channel at birth) is the reason the listener is forced into the
/// future with a whole-struct rebinding.
@@ -554,14 +560,14 @@ struct ForwardedOwner {
/// The `BindTargetHandle` read-half adapter over the forwarded
/// channel's read half: `BiStream` is `Send + Unpin` but NOT `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object — no `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object, with no `Sync`
/// supertrait), so the raw halves cannot meet the handle payloads'
/// `+ Sync` bound directly. A `std::sync::Mutex` guard is the
/// no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`); the RFC 1928
/// BIND data plane is a raw pass-through pumped by ONE `pump_bidi`
/// task at a time, so contention never occurs at runtime — the mutex
/// task at a time, so contention never occurs at runtime; the mutex
/// is the Sync-proof shape, not a concurrency mechanism. A poisoned
/// lock (a panic in one poll) unwraps via `into_inner` — a panic in
/// lock (a panic in one poll) unwraps via `into_inner`, so a panic in
/// one operation does not cascade (AGENTS.md convention 2).
struct SyncHalves {
inner: std::sync::Mutex<tokio::io::ReadHalf<tokio::io::DuplexStream>>,
@@ -582,10 +588,10 @@ impl AsyncRead for SyncHalves {
}
}
/// The write-half payload: the owner ([`ForwardedOwner`] — the
/// The write-half payload: the owner ([`ForwardedOwner`], the
/// adopted channel session) and the write half ride inside one
/// `std::sync::Mutex` (the same no-unsafe Sync proof as
/// [`SyncHalves`] — `TunnelSession` and `WriteHalf<BiStream>` are
/// [`SyncHalves`]: `TunnelSession` and `WriteHalf<BiStream>` are
/// `Send` but not `Sync`). Contention never occurs at runtime (one
/// pump task); a poisoned lock unwraps via `into_inner`.
struct OwnedWrite {
@@ -636,25 +642,25 @@ impl AsyncWrite for OwnedWrite {
}
}
/// The forwarded op's serving registration (the opener half — ADR-008
/// The forwarded op's serving registration (the opener half, ADR-008
/// §3, `register_tunnel_forwarded_openable` + this provider's
/// correlation handler): register on the SOCKS5 producer's serving
/// registry so its connection serves the forwarded op. Call once per
/// channels connection at assembly time — the registry is shared
/// channels connection at assembly time, since the registry is shared
/// across all BIND sessions every [`bindfirst_accept`] factory over
/// the same client fabricates (one token space per producer
/// connection).
///
/// The scope gate is `tunnel:forwarded` on the PRODUCER's per-call
/// identity (the ingress grant — ADR-006 Amendment 1; never implied by
/// identity (the ingress grant, ADR-006 Amendment 1; never implied by
/// `tunnel:open`). The returned registry handle feeds the factories;
/// keep it alongside the client they ride.
///
/// # Errors
///
/// A duplicate `channels/tunnel/forwarded` op id on the registry (an
/// assembly bug, not a runtime condition) — the underlying
/// registration's `String` diagnostic, surfaced (convention 2: no
/// assembly bug, not a runtime condition), and it surfaces the
/// underlying registration's `String` diagnostic (convention 2: no
/// panic path; the caller decides).
pub fn register_forwarded_serving(
core: &ChannelCore,
@@ -673,56 +679,58 @@ pub fn register_forwarded_serving(
/// The composed accept factory (ADR-007 variant 2, producer.md
/// §Wiring `composed::bindfirst_accept`): the `BindAcceptFactory` the
/// producer registration consumes — the BIND accept whose listener
/// producer registration consumes, the BIND accept whose listener
/// lives on an alktunnels 0.2.0 bind-first listen resource
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections` —
/// per-listener ACL for free, hub-relayable).
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections`,
/// giving per-listener ACL for free, hub-relayable).
///
/// Per session the factory:
///
/// 1. Opens the listen channel (`TunnelListener::open` — the
/// 1. Opens the listen channel (`TunnelListener::open`; the
/// establishment bound covers only this open-op round trip,
/// ADR-007); a failed open is a factory failure (reply#1's mapped
/// code — no phantom listener).
/// code, so no phantom listener).
/// 2. Advertises reply#1's BND.ADDR as the REAL `bound` (the open-op
/// reply's `bound` field, the alkcall 0.8.0 reply projection — the
/// reply's `bound` field, the alkcall 0.8.0 reply projection; the
/// graduation's vanilla-fidelity win; no expected-addr stand-in).
/// A producer that supplied no `bound`/`listen` (not a bind-first
/// resource) fails the factory — no stand-in on this variant.
/// resource) fails the factory, with no stand-in on this variant.
/// 3. Inserts the minted `listen` token → oneshot into the
/// correlation registry and returns the accept future parked on
/// it.
///
/// The accept future waits for the ONE forwarded session matching the
/// token (the correlation registry is assembly-side bookkeeping — the
/// token (the correlation registry is assembly-side bookkeeping; the
/// listener does not own the receipt queue), then builds the
/// [`BindTargetHandle`]: reply#2's peer is the forwarded op's `peer`
/// param (the graduation's win — no sentinel), and the data-plane
/// param (the graduation's win, with no sentinel), and the data-plane
/// halves are the [`SyncHalves`]/[`OwnedWrite`] adapters over the
/// adopted channel's `BiStream` with the [`ForwardedOwner`] (the
/// adopted channel session) riding inside the write payload — dropping
/// the handle drops the session AND the halves together.
/// adopted channel session) riding inside the write payload, so
/// dropping the handle drops the session AND the halves together.
///
/// **The client-gone teardown** (the seam contract, end-to-end): the
/// listener and the oneshot receiver ride the accept future — the
/// listener and the oneshot receiver ride the accept future, so the
/// quiet wait's `select!` drop of that future drops the
/// [`TunnelListener`] (its held write half EOFs the listen channel —
/// the far accept dispatcher tears down) AND drops the oneshot
/// [`TunnelListener`] (its held write half EOFs the listen channel,
/// so the far accept dispatcher tears down) AND drops the oneshot
/// receiver (a later forwarded open for that token finds a dead
/// oneshot and drops the channel stream) — no leaked listener, no
/// leaked channel.
/// oneshot and drops the channel stream), leaving no leaked
/// listener and no leaked channel.
///
/// Only TCP-stream listen resources are supported (the SOCKS5 BIND
/// data plane is raw on both ends; a UDP listen substrate would frame
/// — a datagram BIND variant is a different task). A non-TCP `bound`
/// data plane is raw on both ends; a UDP listen substrate would
/// frame, and a datagram BIND variant is a different task). A
/// non-TCP `bound`
/// fails the factory (`NotAllowed`).
///
/// **The 0x02 layering** (review 003 N-2): on the channels path the
/// ADR-006 command gate precedes this factory — an identity without
/// ADR-006 command gate precedes this factory, so an identity
/// without
/// `socks5:bind` never reaches it. A factory-side `NotAllowed` (0x02,
/// the same code the gate uses) therefore means the *tunnel* layer's
/// per-listener ACL (this variant's real policy source), not the
/// command gate — semantically distinct, wire-identical.
/// command gate (the two are semantically distinct, wire-identical).
pub fn bindfirst_accept(
client: Arc<ChannelClient>,
registry: CorrelationRegistry,
@@ -746,12 +754,12 @@ pub fn bindfirst_accept(
.map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?,
Some(SubstrateAddr::Path { path }) => {
return Err(BindAcceptFactoryError::NotAllowed(format!(
"non-tcp bound address `{path}` — this provider serves TCP listeners only"
"non-tcp bound address `{path}`: this provider serves TCP listeners only"
)));
}
None => {
return Err(BindAcceptFactoryError::Shortage(
"the producer supplied no `bound` reply field — not a bind-first \
"the producer supplied no `bound` reply field, so this is not a bind-first \
resource; no stand-in on this variant"
.into(),
));
@@ -759,8 +767,8 @@ pub fn bindfirst_accept(
};
let token = listener.listen().ok_or_else(|| {
BindAcceptFactoryError::Shortage(
"the producer supplied no `listen` correlation token — not a bind-first \
resource; no stand-in on this variant"
"the producer supplied no `listen` correlation token, so this is not a \
bind-first resource; no stand-in on this variant"
.into(),
)
})?;
@@ -771,8 +779,8 @@ pub fn bindfirst_accept(
let fut = async move {
// Force the whole-struct capture: edition-2021 disjoint
// capture would take `listener.channel_id` (a `u32`)
// and drop the listener itself at the factory's return
// — EOF-ing the listen channel at birth. The listener
// and drop the listener itself at the factory's return,
// EOF-ing the listen channel at birth. The listener
// rides the future end-to-end (the seam contract).
let listener = listener;
let ForwardedSession { read, write, peer } = match rx.await {
@@ -784,7 +792,7 @@ pub fn bindfirst_accept(
);
// The registry entry was removed without
// delivery (the forwarded serving half tore
// down — the whole provider is going away).
// down; the whole provider is going away).
// The listener's drop tears the listen
// channel down (the held write half EOFs).
return Err(BindAcceptError::General(
@@ -802,7 +810,7 @@ pub fn bindfirst_accept(
inner: std::sync::Mutex::new(OwnedInner {
// The listener rides INSIDE the payload:
// its keep-alive holds the far accept loop
// (and every per-accept pump it spawned —
// (and every per-accept pump it spawned;
// R-02) alive for the data plane's
// lifetime; dropping the handle drops the
// listener → the listen channel EOFs →
@@ -810,7 +818,7 @@ pub fn bindfirst_accept(
// structural, compiler-enforced). The
// edition-2021 disjoint-capture trap (an
// earlier draft took `listener.channel_id`
// — a `u32` — and dropped the listener at
// (a `u32`) and dropped the listener at
// the factory's return, EOF-ing the listen
// channel at birth) is why the whole
// struct is rebound into the future above.
@@ -832,7 +840,8 @@ pub fn bindfirst_accept(
///
/// - IP literals resolve directly.
/// - A non-IP host fails (reply#1's address is a real socket address
/// on this variant — a hostname bound is the producer's resolution
/// on this variant, but a hostname bound is the producer's
/// resolution
/// surface, not the advertised address's).
///
/// The parse error is a `String` diagnostic (the factory maps it to
@@ -844,7 +853,7 @@ fn resolve_bound(host: &str, port: u16) -> Result<SocketAddr, String> {
}
/// The forwarded op's `peer` param rendered to reply#2's socket
/// address (the graduation's peer fidelity — the param is the real
/// address (the graduation's peer fidelity; the param is the real
/// peer). A `Path` peer (a unix-socket accept) has no RFC-shaped
/// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane
/// is unaffected either way).
@@ -869,9 +878,9 @@ mod egress_unit_tests {
use tokio::io::AsyncWriteExt;
/// The reply-oversize guard fires (review 003 D-2): a reply whose
/// inner bytes exceed the joint bound is a `General` egress error
/// — the guard exists although the tunnel codec's u16 datagram
/// bound upstream should keep every real reply under it.
/// inner bytes exceed the joint bound is a `General` egress error,
/// though the guard exists in case the tunnel codec's u16 datagram
/// bound upstream fails to keep every real reply under it.
#[test]
fn inner_bytes_oversize_guard_fires() {
let addr = ("10.0.0.1", 53u16).to_target_addr().unwrap();
@@ -889,8 +898,8 @@ mod egress_unit_tests {
}
/// The relay task's sender-closed exit (review 003 D-2): the
/// shared queue closed with the egress — the stage exited
/// mid-relay — ends the relay's datagram pump silently (the
/// shared queue closed with the egress (the stage exited
/// mid-relay), so it ends the relay's datagram pump silently (the
/// `tx.send(..).is_err()` early return), distinct from the EOF
/// exit that still reports `Ended`.
#[tokio::test]
@@ -905,7 +914,7 @@ mod egress_unit_tests {
server_side.shutdown().await.unwrap();
drop(server_side);
let handle = tokio::spawn(relay_read(client_side, tx, "k".into()));
// The relay exits on the closed-queue send (silently) — it
// The relay exits on the closed-queue send (silently), and it
// joins promptly with no panic.
tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
@@ -914,7 +923,7 @@ mod egress_unit_tests {
}
/// The relay task's EOF exit still reports `Ended` (the count's
/// decrement path — the shape the integration tests observe
/// decrement path, the shape the integration tests observe
/// indirectly, pinned here directly).
#[tokio::test]
async fn relay_read_reports_ended_on_eof() {
@@ -937,7 +946,7 @@ mod egress_unit_tests {
"a queued datagram: {first:?}"
);
// The second datagram (the batched frame) then the EOF
// marker — drain until the `Ended` marker arrives.
// marker; drain until the `Ended` marker arrives.
let mut saw_end = false;
for _ in 0..3 {
match rx.recv().await.expect("the queue delivers") {
+159 -152
View File
@@ -3,58 +3,59 @@
//!
//! [`Socks5ChannelSession`] is the CONNECT session: it opens
//! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial
//! params — ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit —
//! params, ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit:
//! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session
//! over a `BiStream` is the intended use, not a hack), and after the
//! reply presents the raw byte pipe (0 B framing — the session
//! reply presents the raw byte pipe (0 B framing; the session
//! reads/writes the target's bytes directly). [`BindSession`] is the
//! BIND session: the reply#2 reader (consumer.md §The BIND client,
//! ADR-007) — reply#1 rides the forked client `request()`, reply#2 is
//! BIND session and the reply#2 reader (consumer.md §The BIND client,
//! ADR-007); reply#1 rides the forked client `request()`, reply#2 is
//! hand-read with the forked `read_address` codec (no second codec),
//! then the pass-through data plane. [`AssociateSession`] is the UDP
//! ASSOCIATE session (POC #2's shape — see its docs).
//! ASSOCIATE session (POC #2's shape; see its docs).
//!
//! The BIND session's reply#2 phase is take-guarded (review 002
//! C-1): the first `await_inbound` takes the stream — the plane owns
//! it, the session stays the teardown owner — and a second call
//! C-1): the first `await_inbound` takes the stream (the plane owns
//! it, the session stays the teardown owner), and a second call
//! returns the family's spent shape (`Ok(None)`); the reply#2 reader
//! is that one method alone (`reply1()` is a view, D-1).
//!
//! Target construction rides [`ToTargetAddr`]: IP-string forms parse
//! to IP targets; the domain form passes through unresolved (ADR-012
//! — resolution is the egress's job, never the protocol's).
//! to IP targets; the domain form passes through unresolved (ADR-012;
//! resolution is the egress's job, never the protocol's).
//!
//! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape,
//! consumer.md §The associate session, ADR-003): it opens the
//! channel, negotiates, sends ASSOCIATE, reads the reply — **receives
//! the sentinel `BND.ADDR` and never uses it for addressing** (the
//! channel, negotiates, sends ASSOCIATE, reads the reply, **receiving
//! the sentinel `BND.ADDR` and never using it for addressing** (the
//! handle is the channel). The datagram plane is the framed stage:
//! `send_datagram` frames once (`[len: u16 BE][addr + data]` — the
//! `send_datagram` frames once (`[len: u16 BE][addr + data]`; the
//! framing-once mirror, ADR-003) and `recv_datagram` decodes
//! incrementally with **reader state that persists across calls**
//! (mid-frame position + batched-datagram pending queue — a datagram
//! split across channel chunks survives a caller-driven receive
//! loop; POC #2's finding: the first cut dropped the reader state
//! per call, the 2-byte-prefix chunk test exposed it). `shutdown`
//! half-closes the stream — the mux's implicit-EOF sentinel reaches
//! (mid-frame position + batched-datagram pending queue, so a
//! datagram split across channel chunks survives a caller-driven
//! receive loop; POC #2's finding: the first cut dropped the reader
//! state per call, the 2-byte-prefix chunk test exposed it). `shutdown`
//! half-closes the stream; the mux's implicit-EOF sentinel reaches
//! the producer's stage as EOF and the association ends (association
//! lifetime = channel lifetime).
//!
//! Teardown ownership (alktunnels ADR-005 posture, inherited via
//! ADR-004): the session owns the adopted channel entry. On Drop it
//! reaps it (`ChannelManager::teardown_channel` — sync, best-effort;
//! `Drop` cannot await) and the data plane EOFs structurally (the
//! reaps it (`ChannelManager::teardown_channel`, sync and
//! best-effort; `Drop` cannot await) and the data plane EOFs
//! structurally (the
//! mux's implicit-EOF sentinel on the dropped write half). The
//! teardown-at-birth discipline covers the in-stream refusal path:
//! a refused ASSOCIATE tears the adopted channel down before the
//! error returns — a refused session never leaves a phantom channel.
//! error returns; a refused session never leaves a phantom channel.
//! The session does **not** implement `Clone` (compile-asserted on
//! the struct): aliasing a session would alias its teardown and its
//! reader state.
//!
//! Error surface (consumer.md §Error surface): open failures are the
//! typed [`SocksOpenError`] reasons (ADR-049 §4 — a refused session
//! typed [`SocksOpenError`] reasons (ADR-049 §4; a refused session
//! never yields a phantom channel); a refused ASSOCIATE is an RFC
//! reply code surfaced through [`SocksSessionError::Rfc`].
@@ -76,11 +77,11 @@ use crate::rfc1928::{ReplyError, Socks5Command, SocksError, UdpHeaderError};
use crate::wire::{frame, DatagramReader, WireError};
/// The three session constructors' shared prologue (review 002 N-6):
/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`
/// — the trivial params, ADR-001), join the halves into the channel's
/// open the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// the trivial params, ADR-001), join the halves into the channel's
/// `BiStream`, run the command negotiation over the forked client
/// typestate, and on any negotiation failure reap the adopted channel
/// before the error returns (teardown-at-birth — the phantom-channel
/// before the error returns (teardown-at-birth; the phantom-channel
/// property, in one place instead of three). `Ok` yields the channel
/// id, the negotiated stream, and the cloned manager for the session
/// to own.
@@ -112,17 +113,17 @@ where
/// The cred-bearing front door's re-origination prologue (the
/// token-carrying twin of [`open_and_negotiate`]): open the channel
/// with the payload `auth_token` riding the `call.requested` payload
/// (CF-005 precedence (1) — the producer-side dispatch resolves the
/// (CF-005 precedence (1): the producer-side dispatch resolves the
/// token through its `ServingConfig.identity_provider` and runs the
/// ACL gate against the mapped identity), join the halves, run the
/// command negotiation, and reap on failure — the same phantom-channel
/// command negotiation, and reap on failure, the same phantom-channel
/// property. `Ok` yields the channel id, the negotiated stream, and
/// the cloned manager.
///
/// The token path needs the raw `call.requested` payload (`auth_token`
/// is a payload-level field, ADR-017 §7), so the open rides
/// `CallConnection::call_with_payload` + `adopt_channel` — the same
/// two-step `open_channel` performs, with the token added. The
/// `CallConnection::call_with_payload` + `adopt_channel` (the same
/// two-step `open_channel` performs, with the token added). The
/// client's call connection is taken once and cached in the returned
/// client (the door's `TokenClient` owns it for the door's lifetime).
async fn open_with_token_and_negotiate<T, F, Fut>(
@@ -157,7 +158,7 @@ where
/// The spent-session marker (the gate review's R-2,
/// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession
/// ::into_inner`] / [`AssociateSession::into_inner`] return it when
/// the pipe/stream was already taken — reachable only by calling
/// the pipe/stream was already taken, reachable only by calling
/// twice on a session whose inner is gone. Carries nothing: a spent
/// session's teardown is already done (the reap ran with the take).
#[derive(Debug, thiserror::Error)]
@@ -183,9 +184,9 @@ pub struct Socks5ChannelSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// The CONNECT reply's BND.ADDR — captured at `connect` (the
/// The CONNECT reply's BND.ADDR, captured at `connect` (the
/// forked client `request()` returns it once; the front door's
/// re-origination re-encodes the vanilla reply from it — the RFC
/// re-origination re-encodes the vanilla reply from it, the RFC
/// codec at the door boundary only, ADR-003).
reply_addr: Option<TargetAddr>,
}
@@ -201,15 +202,15 @@ impl std::fmt::Debug for Socks5ChannelSession {
impl Socks5ChannelSession {
/// CONNECT to `target` through a produced SOCKS5 resource: opens
/// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`
/// — the trivial params, ADR-001), drives the no-auth handshake
/// the channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`,
/// the trivial params, ADR-001), drives the no-auth handshake
/// and the CONNECT request in-stream over the channel's
/// `BiStream`, and returns the session owning both.
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused target surfaces the RFC reply code through
/// [`SocksSessionError::Rfc`] — and the adopted channel is torn
/// [`SocksSessionError::Rfc`], and the adopted channel is torn
/// down before the error returns (no phantom channel).
pub async fn connect(
client: &ChannelClient,
@@ -233,8 +234,8 @@ impl Socks5ChannelSession {
/// The cred-bearing front door's CONNECT re-origination (the
/// token-carrying twin of [`Self::connect`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's
/// identity provider resolves the door's mapped identity and its
/// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity, and its
/// scopes gate the open), then the same in-stream negotiation over
/// the channel's `BiStream`. Error surface identical.
pub async fn connect_with_token(
@@ -265,7 +266,7 @@ impl Socks5ChannelSession {
self.channel_id
}
/// The CONNECT reply's BND.ADDR — the in-band reply's bound
/// The CONNECT reply's BND.ADDR: the in-band reply's bound
/// address, captured at `connect` (the forked client `request()`
/// returns it once). The front door's re-origination re-encodes
/// the vanilla reply from it (the RFC codec at the door boundary
@@ -275,8 +276,8 @@ impl Socks5ChannelSession {
self.reply_addr.clone()
}
/// The post-CONNECT pipe as split read/write halves (0 B framing
/// — raw bytes to/from the target). The halves borrow the
/// The post-CONNECT pipe as split read/write halves (0 B framing,
/// raw bytes to/from the target). The halves borrow the
/// session: teardown stays owned (`close`/`Drop`). `None` after
/// [`Self::into_inner`] took the pipe.
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -284,16 +285,16 @@ impl Socks5ChannelSession {
Some(tokio::io::split(inner))
}
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use —
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps).
/// The session rides along still owning the channel's teardown:
/// drop the pair (or `close` the session) when done — dropping
/// `inner` EOFs the data plane structurally.
/// drop the pair (or `close` the session) when done, since
/// dropping `inner` EOFs the data plane structurally.
///
/// [`SpentSessionError`] on a spent session (the pipe already
/// taken): a spent session's manager entry is always reaped
/// already (`close`/`shutdown`/`Drop` all take it), so dropping
/// it loses nothing — no panic path (AGENTS.md convention 2).
/// it loses nothing (no panic path, AGENTS.md convention 2).
pub fn into_inner(mut self) -> Result<TakenInner, SpentSessionError> {
match self.inner.take() {
Some(inner) => Ok(TakenInner {
@@ -317,9 +318,9 @@ impl Socks5ChannelSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -332,7 +333,7 @@ impl Socks5ChannelSession {
impl Drop for Socks5ChannelSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) {
@@ -342,8 +343,8 @@ impl Drop for Socks5ChannelSession {
}
/// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md
/// §The associate session, ADR-003): the channel is the relay handle
/// — the reply's sentinel `BND.ADDR` is received and never used for
/// §The associate session, ADR-003): the channel is the relay handle;
/// the reply's sentinel `BND.ADDR` is received and never used for
/// addressing, the datagram plane is the framed stage on the channel
/// (`[len: u16 BE][addr + data]`, both directions), and the
/// association's lifetime is the channel's.
@@ -352,7 +353,7 @@ impl Drop for Socks5ChannelSession {
/// = one ASSOCIATE, convention 10) and owns its reader state: the
/// incremental [`DatagramReader`] (mid-frame position) and the
/// pending-queue of batched datagrams both **persist across
/// `recv_datagram` calls** — a datagram split across channel chunks
/// `recv_datagram` calls**; a datagram split across channel chunks
/// survives a caller-driven receive loop (POC #2's finding, the
/// first cut dropped the reader state per call). It does **not**
/// implement `Clone`:
@@ -370,17 +371,17 @@ pub struct AssociateSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// The incremental length-codec decoder — its mid-frame state
/// The incremental length-codec decoder; its mid-frame state
/// persists across `recv_datagram` calls (a datagram may be split
/// across channel chunks; the same `DatagramReader` the producer
/// stage runs).
reader: DatagramReader,
/// Datagrams decoded beyond the one returned (a chunk may batch
/// several datagrams — drained by later `recv_datagram` calls).
/// The batched datagrams are stored decoded (`(addr, payload)` —
/// several datagrams, drained by later `recv_datagram` calls).
/// The batched datagrams are stored decoded (`(addr, payload)`,
/// the same shape `recv_datagram` returns).
pending: std::collections::VecDeque<(TargetAddr, Bytes)>,
/// The reply's BND.ADDR — received for RFC fidelity, unused for
/// The reply's BND.ADDR, received for RFC fidelity, unused for
/// addressing (wrapper-aware clients sendto the channel; ADR-003).
relay_addr: Option<TargetAddr>,
}
@@ -398,13 +399,13 @@ impl std::fmt::Debug for AssociateSession {
impl AssociateSession {
/// ASSOCIATE through a produced SOCKS5 resource: opens the
/// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the
/// channel (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the
/// trivial params, ADR-001), drives the no-auth handshake and the
/// UDP ASSOCIATE request in-stream over the channel's `BiStream`,
/// and reads the reply. The DST.ADDR of the ASSOCIATE command
/// mirrors the vanilla shape — the unspecified address (`[::]:0`),
/// mirrors the vanilla shape: the unspecified address (`[::]:0`),
/// as the forked `Socks5Datagram` sends. The reply's BND.ADDR is
/// the producer's sentinel (`127.0.0.1:0` on the in-band path) —
/// the producer's sentinel (`127.0.0.1:0` on the in-band path),
/// captured as [`Self::relay_addr`], never used for addressing
/// (ADR-003: the handle is the channel).
///
@@ -412,7 +413,7 @@ impl AssociateSession {
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused ASSOCIATE (the missing-egress `CommandNotSupported`
/// 0x07 or a factory failure's mapped code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`] — and the
/// reply code through [`SocksSessionError::Rfc`], and the
/// adopted channel is torn down before the error returns (no
/// phantom channel).
pub async fn associate(client: &ChannelClient) -> Result<Self, SocksSessionError> {
@@ -441,7 +442,7 @@ impl AssociateSession {
/// The cred-bearing front door's ASSOCIATE re-origination (the
/// token-carrying twin of [`Self::associate`]): the open rides
/// the payload `auth_token` (CF-005 precedence (1)). Not called
/// by the landed TCP door — its ASSOCIATE arm refuses
/// by the landed TCP door, whose ASSOCIATE arm refuses
/// RFC-faithfully before any session is opened (the datagram
/// plane is the UDP door's task, ADR-003); the constructor is
/// the session family's completeness (every command carries the
@@ -482,7 +483,7 @@ impl AssociateSession {
/// The reply's sentinel `BND.ADDR` (RFC fidelity: the relay
/// address the vanilla shape would sendto; the wrapper-aware
/// session never uses it — the handle is the channel, ADR-003).
/// session never uses it (the handle is the channel, ADR-003).
/// `None` after a taken stream (the spent-session posture).
pub fn relay_addr(&self) -> Option<TargetAddr> {
self.relay_addr.clone()
@@ -490,10 +491,10 @@ impl AssociateSession {
/// Send one datagram to `dst`: the RFC address encoding + the
/// payload, length-framed **exactly once** by the wire codec
/// ([`frame`] — the framing-once mirror, ADR-003) and written to
/// ([`frame`], the framing-once mirror, ADR-003) and written to
/// the channel. A zero-length payload is a legal empty datagram
/// (the F-2 invariant at payload level). Rejects
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time —
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time,
/// the joint bound (`len(addr) + len(payload) > 65535`, the u16
/// length field would wrap) and the address-encoding refusals.
pub async fn send_datagram(
@@ -516,28 +517,28 @@ impl AssociateSession {
}
/// Receive the next datagram: decode incrementally from the
/// channel's read half — `(source_addr, payload)` per datagram,
/// `Ok(None)` on channel EOF (the association's end — the
/// association lifetime is the channel's, ADR-003).
/// channel's read half (`(source_addr, payload)` per datagram),
/// `Ok(None)` on channel EOF (the association's end, per the
/// association-lifetime contract, ADR-003).
///
/// **The reader state persists across calls** (POC #2's finding,
/// ADR-003): the incremental [`DatagramReader`]'s mid-frame
/// position survives a caller-driven receive loop — a datagram
/// split across channel chunks reassembles across calls — and
/// position survives a caller-driven receive loop (a datagram
/// split across channel chunks reassembles across calls), and
/// frames decoded beyond the one returned (a chunk may batch
/// several datagrams) queue in `pending` for later calls.
///
/// Fail-loud shapes (never a silent partial datagram): a stream
/// end mid-frame is the truncated-datagram condition (an
/// `UnexpectedEof` I/O error through [`SocksSessionError::Rfc`]);
/// a frame the codec refuses (garbage ATYP — any post-reply
/// a frame the codec refuses (garbage ATYP: any post-reply
/// control byte) errors loud through [`WireError`]'s
/// `UdpHeaderError` mapping and ends the caller's loop.
pub async fn recv_datagram(
&mut self,
) -> Result<Option<(TargetAddr, Bytes)>, SocksSessionError> {
// The batched-datagram pending queue first (a chunk may have
// batched several frames — drain in order).
// batched several frames, drained in order).
if let Some(dg) = self.pending.pop_front() {
return Ok(Some(dg));
}
@@ -550,7 +551,7 @@ impl AssociateSession {
loop {
let n = socket.read(&mut chunk).await?;
if n == 0 {
// Channel EOF = association end (the clean exit) —
// Channel EOF = association end (the clean exit);
// fail-loud if a frame was in flight (never a silent
// partial datagram; the reader state is the teardown
// diagnostic).
@@ -580,7 +581,7 @@ impl AssociateSession {
/// The underlying stream as split read/write halves (the front
/// door's bridge seam: a raw pipe to a vanilla client over the
/// framed stage — the door's RFC header codec owns the frames at
/// framed stage; the door's RFC header codec owns the frames at
/// the boundary, ADR-003). `None` after the stream was taken (the
/// spent-session posture).
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
@@ -588,7 +589,7 @@ impl AssociateSession {
Some(tokio::io::split(inner))
}
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use —
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use;
/// the forked client type's socket accessors, owned-half pumps;
/// the POC's `raw_mut` seam, widened). The session rides along
/// still owning the channel's teardown: drop the pair (or
@@ -597,7 +598,7 @@ impl AssociateSession {
/// [`SpentSessionError`] on a spent session (the stream already
/// taken): a spent session's manager entry is always reaped
/// already (`shutdown`/`close`/`Drop` all take it), so dropping it
/// loses nothing — no panic path (AGENTS.md convention 2), and the
/// loses nothing (no panic path, AGENTS.md convention 2), and the
/// `Err` side stays small (a spent session is not carried).
pub fn into_inner(mut self) -> Result<TakenAssociateInner, SpentSessionError> {
match self.inner.take() {
@@ -640,9 +641,9 @@ impl AssociateSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -655,8 +656,8 @@ impl AssociateSession {
impl Drop for AssociateSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's stage —
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's stage,
/// the association ends with the session's lifetime). Dropping
/// without `shutdown`/`close` never leaks the channel entry.
fn drop(&mut self) {
@@ -668,7 +669,7 @@ impl Drop for AssociateSession {
/// The owned stream [`AssociateSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown.
pub struct TakenAssociateInner {
/// The teardown owner — reap via `shutdown`/`close`/`Drop`.
/// The teardown owner: reap via `shutdown`/`close`/`Drop`.
pub session: AssociateSession,
/// The framed datagram stage's stream (the forked client type
/// over the channel's `BiStream`).
@@ -678,7 +679,7 @@ pub struct TakenAssociateInner {
/// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown.
pub struct TakenInner {
/// The teardown owner — reap via `close`/`Drop`.
/// The teardown owner: reap via `close`/`Drop`.
pub session: Socks5ChannelSession,
/// The post-CONNECT pipe (the forked client type over the
/// channel's `BiStream`).
@@ -694,7 +695,7 @@ pub type SplitPipe<'s> = (
);
/// A completed BIND reply#1 (consumer.md §The BIND client, ADR-007's
/// client-side phase reading): the channel is open, reply#1 carried
/// BND.ADDR — the advertised address the app server dials (the one
/// BND.ADDR, the advertised address the app server dials (the one
/// real address on the channels path: the local variant's bound
/// listener; the composed variant's expected far-side address; the
/// wrapper-aware sentinel `0.0.0.0:0` on the in-band default). The
@@ -704,15 +705,15 @@ pub type SplitPipe<'s> = (
/// one-reader decision): it carries the advertised address and the
/// raw pre-reply#2 stream ([`Self::raw_mut`], the front-door bridge
/// seam); the one normative reply#2 reader is
/// [`BindSession::await_inbound`] alone, take-guarded there — a
/// [`BindSession::await_inbound`] alone, take-guarded there, so a
/// fresh handle per call cannot double-read anything.
///
/// The session owns the adopted channel — teardown stays there
/// The session owns the adopted channel, so teardown stays there
/// (reap via the session; this handle borrows into it). No `Clone`
/// (the compile-assert rides [`BindSession`]).
pub struct BindReply1<'s> {
inner: &'s mut Socks5Stream<BiStream>,
/// Reply#1's BND.ADDR — the advertised address.
/// Reply#1's BND.ADDR, the advertised address.
pub bind_addr: TargetAddr,
}
@@ -725,11 +726,11 @@ impl BindReply1<'_> {
}
/// The post-reply#2 data plane (the pass-through to the accepted
/// inbound peer): 0 B framing — reads/writes ride the channel
/// inbound peer): 0 B framing; reads/writes ride the channel
/// directly. The plane **owns** the stream (review 002 C-1's
/// take-shape — the taken inner pairs with the session that stays
/// take-shape: the taken inner pairs with the session that stays
/// the teardown owner, the `TakenInner` pairing posture); the
/// [`BindSession`] keeps the channel's teardown — drop it (or
/// [`BindSession`] keeps the channel's teardown; drop it (or
/// `close` it) when done; dropping the plane EOFs the pipe
/// structurally (the mux's implicit-EOF sentinel). Accessors:
/// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`]
@@ -737,7 +738,7 @@ impl BindReply1<'_> {
pub struct BindDataPlane {
inner: Socks5Stream<BiStream>,
/// Reply#2's peer address (the RFC's "the address of the incoming
/// connection" — the attribution surface, in-band).
/// connection", the attribution surface, in-band).
pub peer: TargetAddr,
}
@@ -751,7 +752,7 @@ impl BindDataPlane {
}
/// The underlying stream (the forked client type's socket
/// accessor, borrowed — the POC's `get_socket_mut` shape).
/// accessor, borrowed, i.e. the POC's `get_socket_mut` shape).
pub fn get_socket_mut(&mut self) -> &mut BiStream {
self.inner.get_socket_mut()
}
@@ -791,7 +792,7 @@ impl tokio::io::AsyncWrite for BindDataPlane {
}
}
/// The BIND consumer session (the `bind()` entry namespace — the POC
/// The BIND consumer session (the `bind()` entry namespace, the POC
/// #5 shape): opens the channel, negotiates, sends BIND, reads
/// reply#1 (`bind`), then awaits reply#2 + the data plane
/// (`await_inbound`). The session owns the adopted channel: on Drop
@@ -807,27 +808,29 @@ impl tokio::io::AsyncWrite for BindDataPlane {
/// Error surface: a refused BIND (the command gate's 0x02) or a
/// factory failure's reply#1 code surfaces from `bind` as
/// [`SocksSessionError::Rfc`] (the adopted channel is torn down
/// before the error returns — teardown-at-birth, the phantom-channel
/// property); accept-stage failures surface at `await_inbound` as
/// reply#2's mapped code ([`SocksSessionError::Rfc`]) — the channel
/// before the error returns; that is teardown-at-birth, the
/// phantom-channel property); accept-stage failures surface at
/// `await_inbound` as reply#2's mapped code
/// ([`SocksSessionError::Rfc`]); the channel
/// is still open there (the listener existed; the session lives until
/// the app closes it; the inner is restored, see the take-guard
/// below).
///
/// The reply#2 phase is take-guarded (review 002
/// C-1): the first `await_inbound` takes the inner stream — the
/// returned plane owns it (the `TakenInner` pairing posture; the
/// session stays the teardown owner) — so a second call hits the
/// C-1): the first `await_inbound` takes the inner stream (the
/// returned plane owns it; the `TakenInner` pairing posture, the
/// session stays the teardown owner), so a second call hits the
/// `None` arm and returns `Ok(None)`: the session family's spent
/// shape, with no re-read of the live data plane possible. A failed
/// read restores the inner (the phase is only consumed on success;
/// the producer ends the session on every post-reply#1 failure exit,
/// so a re-call EOFs — never a live data-plane re-read either way).
/// so a re-call EOFs and never does a live data-plane re-read either
/// way).
pub struct BindSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// Reply#1's BND.ADDR — captured at `bind` (the forked client's
/// Reply#1's BND.ADDR, captured at `bind` (the forked client's
/// `request()` returns it once; the session carries it for the
/// app server's out-of-band dial).
bind_addr: Option<TargetAddr>,
@@ -845,17 +848,17 @@ impl std::fmt::Debug for BindSession {
impl BindSession {
/// BIND through a produced SOCKS5 resource: opens the channel
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the trivial
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)`, the trivial
/// params, ADR-001), drives the no-auth handshake and the BIND
/// request in-stream over the channel's `BiStream`, and reads
/// reply#1 (carrying BND.ADDR — the forked client `request()`'s
/// reply#1 (carrying BND.ADDR, the forked client `request()`'s
/// normal path). The returned session owns the channel; the
/// advertised address is [`BindReply1::bind_addr`].
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused BIND — the command gate's `ConnectionNotAllowed` 0x02
/// or a factory failure's mapped reply#1 code — surfaces the RFC
/// refused BIND (the command gate's `ConnectionNotAllowed` 0x02,
/// or a factory failure's mapped reply#1 code) surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`], and the adopted
/// channel is torn down before the error returns (no phantom
/// channel).
@@ -881,7 +884,7 @@ impl BindSession {
/// The cred-bearing front door's BIND re-origination (the
/// token-carrying twin of [`Self::bind`]): the open rides the
/// payload `auth_token` (CF-005 precedence (1) — the producer's
/// payload `auth_token` (CF-005 precedence (1): the producer's
/// identity provider resolves the door's mapped identity; the
/// BIND command gate reads `socks5:bind` from it, ADR-006), then
/// the same in-stream BIND phase model over the channel's
@@ -915,12 +918,12 @@ impl BindSession {
}
/// Reply#1's view: the advertised BND.ADDR (the app server dials
/// it out-of-band — the RFC's original role for the address) and
/// it out-of-band, the RFC's original role for the address) and
/// the raw pre-reply#2 stream ([`BindReply1::raw_mut`], the
/// front-door bridge seam). A **view**, not a reader (review 002
/// D-1's one-reader decision): the one normative reply#2 reader
/// is [`Self::await_inbound`]; the handle can be re-borrowed
/// freely — it carries no read phase.
/// freely; it carries no read phase.
pub fn reply1(&mut self) -> Option<BindReply1<'_>> {
let inner = self.inner.as_mut()?;
Some(BindReply1 {
@@ -931,22 +934,22 @@ impl BindSession {
/// Await the inbound accept (reply#2) and take the pass-through
/// data plane. The one normative reply#2 reader (review 002 C-1 +
/// D-1): the phase is **take-guarded** — the read consumes the
/// D-1): the phase is **take-guarded**: the read consumes the
/// session's inner stream (the plane owns it; the session stays
/// the teardown owner) — so a second call cannot re-read the
/// the teardown owner), so a second call cannot re-read the
/// live data plane. Reply#2 is hand-read with the forked
/// `read_address` codec — reply#1's codec applied again, no
/// `read_address` codec (reply#1's codec applied again, no
/// second codec (POC #5's `read_reply2` sketch; ADR-011's
/// rationale).
///
/// `Ok(None)` after the plane was taken (the spent-session
/// posture — the doc claim this guard makes true). A failed read
/// posture, the doc claim this guard makes true). A failed read
/// restores the inner (the phase is not consumed on failure): the
/// channel stays owned (reply#1 succeeded — the listener existed;
/// channel stays owned (reply#1 succeeded; the listener existed;
/// reap via `close`/`Drop`), the pipe stays reachable
/// (`split`), and a re-call re-reads against the already-ended
/// session (the producer ends it on every post-reply#1 failure
/// exit — the read EOFs; never a live data-plane re-read).
/// exit, and the read EOFs (never a live data-plane re-read).
pub async fn await_inbound(&mut self) -> Result<Option<BindDataPlane>, SocksSessionError> {
let Some(mut inner) = self.inner.take() else {
return Ok(None);
@@ -961,8 +964,8 @@ impl BindSession {
}
/// The pass-through pipe as split read/write halves (0 B framing
/// — raw bytes to/from the accepted peer). Valid in the reply#1
/// phase (pre-reply#2 bytes ride the same channel — the
/// raw bytes to/from the accepted peer). Valid in the reply#1
/// phase (pre-reply#2 bytes ride the same channel, the
/// front-door bridge seam); post-[`Self::await_inbound`] the
/// plane owns the stream (the take-guard), so the data plane's
/// halves ride `BindDataPlane`/its `into_inner` instead. `None`
@@ -985,9 +988,9 @@ impl BindSession {
/// The adopted channel's teardown (`teardown_channel` returns a
/// producer-side pump-task handle the caller may abort after the
/// drain — the channels-call path's shape). The handle is
/// drain, the channels-call path's shape). The handle is
/// deliberately dropped here: the producer-side pump finishes via
/// EOF propagation (the graceful shape — aborting would cut
/// EOF propagation (the graceful shape, since aborting would cut
/// mid-flight data); `clear_all` aborts instead (the substrate's
/// abrupt path).
fn reap(&mut self) -> bool {
@@ -1000,7 +1003,7 @@ impl BindSession {
impl Drop for BindSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// sync, best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) {
@@ -1012,14 +1015,15 @@ impl Drop for BindSession {
/// Map a datagram-frame decode error ([`WireError`]) into the RFC
/// vocabulary the session surface carries: the address-encoding
/// refusal is the forked [`AddrError`] class (the same sub-layout
/// the forked codec validates — one vocabulary), the protocol-garbage
/// the forked codec validates, hence one vocabulary), the
/// protocol-garbage
/// shapes (bad ATYP / a short frame / non-UTF-8 domain / a `len == 0`
/// frame) surface as the RFC UDP-header error class
/// ([`UdpHeaderError`]) — the post-reply-garbage semantic, the
/// consumer-side mirror of the producer stage's
/// ([`UdpHeaderError`]), mirroring the post-reply-garbage semantic as
/// the consumer-side mirror of the producer stage's
/// `UndecodableFrame` exit. The oversize family is unreachable on
/// the receive path (the length codec's frame-time bound makes a
/// declared length undecodable only via the garbage shapes) — it
/// declared length undecodable only via the garbage shapes); it maps
/// maps through the UDP-header class too (fail-loud either way).
fn wire_error(err: WireError) -> SocksSessionError {
match err {
@@ -1032,10 +1036,10 @@ fn wire_error(err: WireError) -> SocksSessionError {
}
/// Read reply#2 off the raw stream (the RFC's second reply: VER, REP,
/// RSV, ATYP, addr, port — the POC #5 `read_reply2` sketch): the 4-byte
/// RSV, ATYP, addr, port, the POC #5 `read_reply2` sketch): the 4-byte
/// head, the version pin, the reply code mapped through the forked
/// `ReplyError::from_u8`, then the address via the forked public
/// `read_address` codec — reply#1's codec applied again, **no second
/// `read_address` codec (reply#1's codec applied again, **no second
/// codec** (ADR-011's rationale).
async fn read_reply2<T>(inner: &mut Socks5Stream<T>) -> Result<TargetAddr, SocksSessionError>
where
@@ -1060,16 +1064,16 @@ where
/// The cred-bearing front door's channel surface (the
/// token-carrying twin of a `ChannelClient`'s open path): wraps the
/// client's channel-0 [`CallConnection`] so an open call can carry the
/// payload `auth_token` (CF-005 precedence (1) — the producer-side
/// payload `auth_token` (CF-005 precedence (1): the producer-side
/// dispatch resolves the token through its
/// `ServingConfig.identity_provider` and runs the ACL gate against the
/// resolved identity, ADR-017 §7). The client's call connection is
/// taken once at construction (the client's own calling surface ends
/// — a door client is a door client); the manager stays shared for
/// a door client is a door client); the manager stays shared for
/// `adopt_channel`/teardown.
///
/// The token is the credential table's *identity id* (the resolution
/// seam's vocabulary — the SOCKS password itself never rides it, and
/// seam's vocabulary; the SOCKS password itself never rides it, and
/// the vanilla client never sees the identity). The door's producer
/// resolves `token → Identity` through its
/// `ServingConfig.identity_provider`; the identity's scopes govern the
@@ -1087,7 +1091,7 @@ impl TokenClient {
///
/// [`TokenClientError::CallConnectionTaken`] when the client's
/// call connection was already taken (a client is a single
/// calling surface — the door-only contract; convention 2: no
/// calling surface, the door-only contract; convention 2: no
/// panic path on a double wrap).
pub async fn wrap(client: &ChannelClient) -> Result<Self, TokenClientError> {
let call = client
@@ -1100,7 +1104,7 @@ impl TokenClient {
})
}
/// The manager (adopt/teardown — the token sessions' prologue uses
/// The manager (adopt/teardown; the token sessions' prologue uses
/// it; the sessions' teardown owns it per session).
pub fn manager(&self) -> &ChannelManager {
&self.manager
@@ -1109,9 +1113,9 @@ impl TokenClient {
/// The raw open call with the `auth_token` payload (CF-005
/// precedence (1)): the payload shape mirrors
/// [`ChannelClient::call_open_op`]'s `call.requested` envelope with
/// the token added (ADR-017 §7 — the hub-forwarding path's shape).
/// the token added (ADR-017 §7, the hub-forwarding path's shape).
/// Returns the adopted `channel_id` (the reply's `channel_id`
/// extracted — the adopt is the caller's next step, mirroring
/// extracted; the adopt is the caller's next step, mirroring
/// `open_channel`'s two halves).
pub async fn open_with_token(&self, token: Vec<u8>) -> Result<u32, SocksOpenError> {
let payload = serde_json::json!({
@@ -1133,11 +1137,11 @@ impl TokenClient {
}
/// Why a `TokenClient` refused to construct (the typed construction
/// error — the client's calling surface was already taken;
/// error: the client's calling surface was already taken;
/// convention 2: no panic path).
#[derive(Debug, thiserror::Error)]
pub enum TokenClientError {
#[error("the channel client's call connection was already taken (a client is one calling surface — the door-only contract)")]
#[error("the channel client's call connection was already taken (a client is one calling surface, the door-only contract)")]
CallConnectionTaken,
}
@@ -1171,7 +1175,7 @@ mod tests {
async fn spent_bind_await_inbound_returns_the_spent_shape() {
// The C-1 take-guard's spent arm: a session whose inner is
// gone (taken at the first read / the teardown) returns the
// family's `Ok(None)` — no re-read, no panic.
// family's `Ok(None)`: no re-read, no panic.
let mut session = BindSession {
channel_id: 9,
manager: None,
@@ -1209,7 +1213,7 @@ mod tests {
inner: None,
reply_addr: None,
};
assert!(!session.reap(), "no manager — nothing reaped");
assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no pipe after teardown");
}
@@ -1217,7 +1221,7 @@ mod tests {
fn spent_session_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session
// path is reachable (a session whose inner is gone), so it
// must be a typed `Err` — no panic path.
// must be a typed `Err`, with no panic path.
let session = Socks5ChannelSession {
channel_id: 9,
manager: None,
@@ -1270,7 +1274,7 @@ mod tests {
pending: Default::default(),
relay_addr: None,
};
assert!(!session.reap(), "no manager — nothing reaped");
assert!(!session.reap(), "no manager, so nothing was reaped");
assert!(session.split().is_none(), "no stream after teardown");
assert!(session.relay_addr().is_none());
}
@@ -1279,7 +1283,8 @@ mod tests {
fn spent_associate_into_inner_is_an_err_not_a_panic() {
// The `expect` the review removed (R-2): the spent-session
// path is reachable (`into_inner` twice), so it must be a
// typed `Err` — no panic path. The teardown owner rides back
// typed `Err`, with no panic path. The teardown owner rides
// back
// to the caller either way.
let session = AssociateSession {
channel_id: 9,
@@ -1301,7 +1306,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The frame-source side: two frames, the first one split
// across two writes (the 2-byte-prefix chunk shape — the POC
// across two writes (the 2-byte-prefix chunk shape, the POC
// finding's trigger) and the second batched into the same
// read batch. The session's reader state must survive the
// call boundary: the first call returns the first datagram
@@ -1338,7 +1343,7 @@ mod tests {
assert_eq!(src, addr);
assert_eq!(&second[..], b"second");
// EOF: both frames consumed, the queue empty — `None`.
// EOF: both frames consumed, and the queue empty means `None`.
assert!(session.recv_datagram().await.expect("eof").is_none());
}
@@ -1412,7 +1417,7 @@ mod tests {
#[tokio::test]
async fn send_datagram_on_a_spent_stream_is_the_io_arm() {
// The spent-stream `Io` arm (review 002 N-8): a session whose
// stream is gone errors loud through the typed I/O surface —
// stream is gone errors loud through the typed I/O surface,
// the mirror of the covered spent-`recv` arm.
let mut session = AssociateSession {
channel_id: 9,
@@ -1440,9 +1445,9 @@ mod tests {
);
// The spent-session teardown surfaces: `shutdown` on a spent
// session is a no-op reap (no manager — nothing reaped).
// session is a no-op reap (no manager, so nothing was reaped).
let shutdown = session.shutdown().await.expect("spent shutdown");
assert!(!shutdown, "no manager — nothing reaped");
assert!(!shutdown, "no manager, so nothing was reaped");
}
#[tokio::test]
@@ -1458,7 +1463,7 @@ mod tests {
pending: Default::default(),
relay_addr: None,
};
assert!(!session.close().await, "no manager — nothing reaped");
assert!(!session.close().await, "no manager, so nothing was reaped");
}
#[tokio::test]
@@ -1466,8 +1471,9 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The mid-batch later-frame `Err` arm (review 002 N-8): the
// first frame of a batch decodes `Ok`, a later one is garbage
// — the batch is discarded with the session (fail-loud, never
// first frame of a batch decodes `Ok`, a later one is
// garbage, and the batch is discarded with the session
// (fail-loud, never
// a partial batch delivered while the session stays open).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
@@ -1496,7 +1502,7 @@ mod tests {
"the decode garbage rides the UDP-header class: {err:?}"
);
// The pending `Ok` was dropped with the session — the queue
// The pending `Ok` was dropped with the session; the queue
// stays empty and the stream is spent for further reads (the
// error ended the caller's loop, the fail-loud posture).
assert!(session.pending.is_empty());
@@ -1507,7 +1513,7 @@ mod tests {
// The `Ok` arm of `AssociateSession::into_inner` (review 002
// N-8): the taken stream pairs with the session that stays the
// teardown owner (the `into_inner_pairs_the_pipe_with_the_
// teardown_owner` shape's ASSOCIATE mirror) — and a second
// teardown_owner` shape's ASSOCIATE mirror), and a second
// take is the spent shape.
let (_write_peer, session_writer) = tokio::io::duplex(64);
let (read_peer, session_reader) = tokio::io::duplex(64);
@@ -1579,8 +1585,9 @@ mod tests {
// The front-door-bridge surface (consumer.md) on a *live*
// stream: the halves borrow the session and the raw framed
// bytes pass through both directions (0 B framing — the door's
// RFC header codec would own the frames at the boundary).
// bytes pass through both directions (0 B framing; the
// door's RFC header codec would own the frames at the
// boundary).
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
let framed = wire_frame(&addr, b"split").unwrap().to_vec();
@@ -1610,7 +1617,7 @@ mod tests {
assert_eq!(len, 7 + 5, "one v4-address datagram frame");
drop(read);
// The write half passes through (the bridge seam's shape) —
// The write half passes through (the bridge seam's shape), so
// the peer receives the exact bytes.
write.write_all(&framed).await.unwrap();
write.flush().await.unwrap();
@@ -1646,7 +1653,7 @@ mod tests {
use tokio::io::AsyncWriteExt as _;
// The BIND front-door-bridge surface (consumer.md): the split
// halves in the reply#1 phase — pre-reply#2 bytes ride the
// halves in the reply#1 phase, so pre-reply#2 bytes ride the
// same channel (the doc's claim, exercised on a live stream).
let (mut peer_writer, session_reader) = tokio::io::duplex(1024);
peer_writer.write_all(b"raw").await.unwrap();
@@ -1676,7 +1683,7 @@ mod tests {
// The take-guard is untouched: the reader still reads the
// reply#2 phase (the split halves were a view, not a
// consumer) — the dropped peer EOFs it loud, never the spent
// consumer); the dropped peer EOFs it loud, never the spent
// shape.
let outcome = session.await_inbound().await;
assert!(
@@ -1692,7 +1699,7 @@ mod tests {
// The BIND door's half-close rides the plane's poll_shutdown
// passthrough (and poll_flush rides the write path): write →
// flush → shutdown — the bytes reach the peer and the EOF
// flush → shutdown, so the bytes reach the peer and the EOF
// sentinel follows.
let (_session_writer_unused, session_reader) = tokio::io::duplex(64);
let (mut peer_reader, session_writer) = tokio::io::duplex(64);
@@ -1747,7 +1754,7 @@ mod tests {
}
/// A session over a raw duplex (the reader internals' test
/// harness — the handshake-free path: `skip_auth` bypasses the
/// harness, a handshake-free path: `skip_auth` bypasses the
/// greeting, no reply is expected, the datagram stage rides
/// raw).
fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession {
+136 -120
View File
@@ -1,5 +1,5 @@
//! The consumer's TCP front door (feature `local`, ADR-005/ADR-014;
//! consumer.md §The Front Doors): the ssh `-D` local exposure — a real
//! consumer.md §The Front Doors): the ssh `-D` local exposure, a real
//! `socks5://127.0.0.1:port` endpoint vanilla clients (curl, browsers,
//! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC
//! codec lives only here (ADR-003's "codec at front doors only").
@@ -9,13 +9,13 @@
//! - **The NoAuth door** (POC #1's raw-pipe shape): per accepted
//! connection, a literal raw pipe onto a channel `BiStream`
//! (`copy_bidirectional`, **zero protocol translation**). The
//! producer runs the whole RFC conversation through the pipe — the
//! door adds nothing. The loopback-only / wrapper-aware shape.
//! producer runs the whole RFC conversation through the pipe, and
//! the door adds nothing. The loopback-only / wrapper-aware shape.
//! - **The cred-bearing door** (RFC 1929): the door terminates the
//! local RFC conversation itself — the forked
//! local RFC conversation itself, using the forked
//! `accept_password_auth` shape (`start` →
//! `negotiate_auth(&[PasswordAuthentication])` →
//! `read_username_password` → check → `accept`/`reject`) — then
//! `read_username_password` → check → `accept`/`reject`), then
//! maps the accepted credential through the **static config table**
//! (`user:pass →` channel identity, ADR-005) and **re-originates
//! the SOCKS conversation in-band** via the token-carrying consumer
@@ -33,64 +33,66 @@
//! mapping yields an alkcall [`Identity`] (scopes included); the
//! per-credential identity rides the open-op call's `auth_token`
//! payload (CF-005 precedence (1): payload `auth_token` →
//! `identity_provider` resolution — alkcall `dispatch::
//! `identity_provider` resolution, alkcall `dispatch::
//! resolve_identity`, ADR-017 §7), never a per-credential
//! `ChannelClient`. The token is the mapped identity's id — the
//! `ChannelClient`. The token is the mapped identity's id, the
//! resolution seam's vocabulary; the password itself never rides it.
//! The producer resolves it through its `ServingConfig
//! .identity_provider` and the ACL gate reads the resolved scopes
//! exactly as for any per-call opener identity (CF-005/CF-006). The
//! door checks credentials itself — no forked `AuthMethod` impl
//! door checks credentials itself, with no forked `AuthMethod` impl
//! (ADR-005).
//!
//! **The accept path owns the idle defense** (ADR-014): an accepted
//! connection that sends no first byte within the configured window
//! is closed door-side (an accepted socket is not a channel — the cap
//! is closed door-side (an accepted socket is not a channel, so the
//! cap
//! regime does not bound it); plus the max-concurrent-sessions cap
//! (an accept beyond the bound is refused door-side, no channel
//! opened). The two v1 knobs are constructor config.
//!
//! **The BIND door**: the same listener shape; a vanilla BIND
//! conversation rides [`BindSession`] (reply#1's advertised address
//! passes through to the vanilla client unchanged — 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).
//! passes through to the vanilla client unchanged, since 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).
//!
//! Wiring (consumer.md §Wiring): `FrontDoor::builder()
//! .credentials(cred_table).bind("127.0.0.1:1080").build()` →
//! `serve(channel_client)` — the NoAuth door omits `.credentials`.
//! `serve(channel_client)`; the NoAuth door omits `.credentials`.
//! `serve` runs the accept loop; per-connection failures are logged +
//! isolated (the loop continues — the alktunnels per-accept isolation
//! posture).
//! isolated (the loop continues, per the alktunnels per-accept
//! isolation posture).
//!
//! **The UDP door** ([`UdpFrontDoor`], the POC #2 test-8 shape): the
//! ASSOCIATE front door — a real UDP socket (the relay socket; the
//! ASSOCIATE front door, a real UDP socket (the relay socket; the
//! vanilla client `sendto()`s it) bridged onto one in-band
//! association. The RFC UDP-header codec lives ONLY here (ADR-003's
//! codec-at-doors-only): inbound datagrams decode through
//! `parse_udp_request` and re-send in-band as `{addr, data}` frames
//! (`send_datagram`); replies encode through `new_udp_header` and go
//! back to the client's source address. **FRAG ≠ 0 → refuse** (the
//! datagram is dropped — the v1 posture; fragmentation cannot be
//! datagram is dropped, the v1 posture; fragmentation cannot be
//! expressed in-band, and the association stays healthy). The
//! datagram pump is the two-pump shape (one pump per direction,
//! joined — the ADR-050 contract). Zero payload translation. The
//! joined, per the ADR-050 contract). Zero payload translation. The
//! association's lifetime is the channel's (ADR-003): the association
//! ends when the channel ends; the door's relay socket closes with
//! it. The door-side idle bound (ADR-014): no datagrams from the
//! client within the configured window closes the association (a
//! relay socket is not a channel — the cap regime does not bound
//! it). The reply's sentinel BND.ADDR is received and never used —
//! the handle is the channel; the real relay address is the door's
//! relay socket is not a channel; the cap regime does not bound
//! it). The reply's sentinel BND.ADDR is received and never used,
//! since the handle is the channel; the real relay address is the door's
//! own bound socket, which the vanilla client learns from the TCP
//! door's ASSOCIATE reply (the assembly layer wires the two; the UDP
//! door serves the datagram plane alone). The bind address is
//! constructor config (consumer.md: "the UDP door's bind address is
//! assembly-layer configuration"). Surface shape (recorded per the
//! task spec): a **sibling** `UdpFrontDoor` with its own builder —
//! the UDP door shares no accept loop with the TCP door (UDP has no
//! connections; one relay socket = one association) and the shared
//! task spec): a **sibling** `UdpFrontDoor` with its own builder; the
//! UDP door shares no accept loop with the TCP door (UDP has no
//! connections; one relay socket = one association), and the shared
//! shape would have forced a mode split on every knob.
use std::collections::HashMap;
@@ -114,7 +116,7 @@ use crate::wire::{DatagramReader, WireError};
use bytes::Bytes;
/// One static credential-table entry (ADR-005): `user:pass →` channel
/// identity. The table is the door's credential→identity mapping —
/// identity. The table is the door's credential→identity mapping:
/// static, declarative, assembly-layer.
#[derive(Clone, Debug)]
pub struct DoorCredential {
@@ -123,17 +125,17 @@ pub struct DoorCredential {
/// The RFC 1929 password the vanilla client presents.
pub password: String,
/// The channel identity the accepted credential maps to (scopes
/// included — the open gate and the BIND command gate read them
/// included; the open gate and the BIND command gate read them
/// producer-side). The identity's `id` doubles as the open-op
/// `auth_token` (the resolution seam's vocabulary — the password
/// `auth_token` (the resolution seam's vocabulary: the password
/// never rides it).
pub identity: Identity,
}
/// The credential→identity static config table (ADR-005): exact
/// `username`/`password` lookup. A miss rejects the RFC 1929
/// conversation door-side (no channel opened — the ACL boundary held;
/// the vanilla client got the RFC 1929 rejection).
/// conversation door-side (no channel opened, so the ACL boundary is
/// held; the vanilla client got the RFC 1929 rejection).
#[derive(Clone, Debug, Default)]
pub struct CredentialTable {
entries: HashMap<(String, String), Identity>,
@@ -169,7 +171,7 @@ impl CredentialTable {
}
/// Why one accepted connection's serving task ended (the door's
/// per-connection teardown diagnostic — logged + isolated).
/// per-connection teardown diagnostic, logged + isolated).
#[derive(Debug)]
enum DoorExit {
/// The vanilla client went away (clean end, both variants).
@@ -177,7 +179,7 @@ enum DoorExit {
/// The data plane (local pipe ↔ in-band session) ended.
PlaneEnded,
/// The local RFC half failed (a rejected credential, a malformed
/// conversation, protocol garbage) — the vanilla client's fault.
/// conversation, protocol garbage); the vanilla client's fault.
LocalRfc(SocksError),
/// The in-band open failed (the producer refused the mapped
/// identity, the target dial refused, the channel open errored).
@@ -245,10 +247,10 @@ impl FrontDoorBuilder {
self
}
/// The local bind address — the door's explicit bind decision
/// The local bind address, the door's explicit bind decision
/// (the never-binds posture: the door is the assembly layer's
/// spelled-out choice; loopback is the documented posture).
/// Required: `.build()` is a `Result` — omitting it is the
/// Required: `.build()` is a `Result`; omitting it is the
/// builder's typed construction error (convention 2: no panic
/// path).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
@@ -259,7 +261,7 @@ impl FrontDoorBuilder {
/// The idle-first-byte window (ADR-014's accept-path defense
/// knob 1): an accepted connection that sends no first byte
/// within this window is closed door-side. The window covers the
/// first byte of the local conversation — the NoAuth door's
/// first byte of the local conversation: the NoAuth door's
/// greeting bytes (read producer-side through the pipe) and the
/// cred-bearing door's greeting (read by the door's own RFC 1929
/// half).
@@ -269,11 +271,11 @@ impl FrontDoorBuilder {
}
/// The max-concurrent-*connections* cap (ADR-014's accept-path
/// defense knob 2; review 003 N-3's naming — the mechanism bounds
/// accepted connections, including ones past the idle gate that
/// are merely slow readers, not active RFC sessions): an accept
/// beyond the bound is refused door-side — the socket is closed
/// immediately, no channel opened.
/// defense knob 2; review 003 N-3's naming, for the mechanism
/// bounds accepted connections, including ones past the idle gate
/// that are merely slow readers, not active RFC sessions): an
/// accept beyond the bound is refused door-side; the socket is
/// closed immediately, with no channel opened.
pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap);
self
@@ -291,20 +293,22 @@ impl FrontDoorBuilder {
}
/// Why a door builder refused to construct (the builder's typed
/// construction error — a missing required field; convention 2: no
/// construction error, i.e. a missing required field; convention 2:
/// no
/// panic path on an omitted `.bind`).
#[derive(Debug, thiserror::Error)]
pub enum DoorBuildError {
#[error("the door requires a bind address (.bind) — the bind decision is never implied")]
#[error("the door requires a bind address (.bind); the bind decision is never implied")]
MissingBindAddr,
}
impl FrontDoor {
/// Bind the configured address and run the accept loop
/// (consumer.md §Wiring: `door.serve(channel_client)`). Per-
/// connection failures are logged + isolated — the loop continues
/// (the alktunnels per-accept isolation posture). Runs until the
/// listener fails fatally; the caller owns the task's lifetime.
/// connection failures are logged + isolated, and the loop
/// continues (the alktunnels per-accept isolation posture). Runs
/// until the listener fails fatally; the caller owns the task's
/// lifetime.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let listener = TcpListener::bind(self.bind_addr).await?;
tracing::info!(bind = %self.bind_addr, "front door: listening");
@@ -312,7 +316,7 @@ impl FrontDoor {
}
/// Run the accept loop over an existing listener (the
/// test/assembly seam — the same accept loop without the bind).
/// test/assembly seam, the same accept loop without the bind).
pub async fn serve_on(
self,
listener: TcpListener,
@@ -327,7 +331,7 @@ impl FrontDoor {
// transport, one call connection, many per-credential
// identities riding the open-op `auth_token` payloads). The
// NoAuth door keeps the client's own calling surface
// (`open_channel` — the raw-pipe shape never carries a
// (`open_channel`; the raw-pipe shape never carries a
// token).
let token_client = match credentials {
Some(_) => Some(Arc::new(
@@ -348,11 +352,11 @@ impl FrontDoor {
};
// The session cap (ADR-014 knob 2): refuse beyond the
// bound — `try_acquire` keeps the accept loop free (a
// bound; `try_acquire` keeps the accept loop free (a
// permit returns when a serving task ends and drops its
// guard).
let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "front door: session cap reached — refusing");
tracing::warn!(peer = %peer, "front door: session cap reached; refusing");
drop(stream);
continue;
};
@@ -378,9 +382,10 @@ impl FrontDoor {
}
/// Serve one accepted connection through the door variant split: the
/// idle-first-byte gate first (ADR-014 knob 1 — the door's accept
/// path owns the defense; an accepted socket is not a channel), then
/// the variant.
/// idle-first-byte gate first (ADR-014 knob 1, since the door's
/// accept
/// path owns the defense, and an accepted socket is not a channel),
/// then the variant.
async fn serve_connection(
stream: TcpStream,
peer: SocketAddr,
@@ -392,7 +397,8 @@ async fn serve_connection(
// The idle-first-byte defense: an accepted connection that sends
// no first byte within the window is closed door-side (ADR-014's
// slowloris sliver). The peek waits for readability without
// consuming — the variant's own protocol read picks the byte up.
// consuming, and the variant's own protocol read picks the byte
// up.
let mut first = [0u8; 1];
match tokio::time::timeout(idle, stream.peek(&mut first)).await {
Ok(Ok(_)) => {}
@@ -404,14 +410,14 @@ async fn serve_connection(
}
match (credentials, token_client) {
// The NoAuth door: the literal raw pipe (POC #1's shape —
// The NoAuth door: the literal raw pipe (POC #1's shape, with
// zero protocol translation).
(None, _) => raw_pipe(stream, client).await,
// The cred-bearing door: RFC 1929 terminates locally,
// re-origination rides the token sessions.
(Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await,
// Unreachable by construction (`serve_on` builds the pair) —
// the arm exists to keep the match exhaustive without a
// Unreachable by construction (`serve_on` builds the pair),
// so the arm exists to keep the match exhaustive without a
// panic path (convention 2).
(Some(_), None) => {
DoorExit::Open("the cred-bearing door's token surface was not built".to_string())
@@ -421,8 +427,8 @@ async fn serve_connection(
/// The NoAuth door's raw pipe (POC #1's `front_door_raw_pipe_vanilla_
/// client` shape): open a channel, then `copy_bidirectional` the
/// accepted socket onto the channel `BiStream` — the producer runs
/// the whole RFC conversation through the pipe; the door adds
/// accepted socket onto the channel `BiStream`, so the producer
/// runs the whole RFC conversation through the pipe; the door adds
/// nothing.
async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExit {
let (_channel_id, send, recv) = match client
@@ -451,8 +457,8 @@ async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExi
/// The door never pipes raw post-auth: the vanilla client's command
/// bytes were consumed by the door's own RFC half; the in-band path
/// re-speaks them from the parsed target. The password never crosses
/// the channel as a SOCKS password (the in-band handshake is NoAuth —
/// the mapped identity is the authorization); the channel identity
/// the channel as a SOCKS password (the in-band handshake is NoAuth,
/// so the mapped identity is the authorization); the channel identity
/// never reaches the vanilla client.
async fn cred_bearing(
mut stream: TcpStream,
@@ -469,7 +475,7 @@ async fn cred_bearing(
// The in-band re-origination: the per-credential identity rides
// the open-op call's `auth_token` payload (CF-005 precedence
// (1)). The token is the mapped identity's id — the resolution
// (1)). The token is the mapped identity's id, the resolution
// seam's vocabulary; the password itself never rides it.
let token = identity.id.as_bytes().to_vec();
@@ -486,7 +492,7 @@ async fn cred_bearing(
Err(err) => {
// The in-band refusal's mapped code is re-encoded to
// the vanilla client (the RFC codec at the door
// boundary — ADR-003): a dial refusal is reply#1's
// boundary, ADR-003): a dial refusal is reply#1's
// code, never a bare EOF.
write_refusal_reply(&mut stream, &err).await;
return DoorExit::Open(err.to_string());
@@ -508,8 +514,9 @@ async fn cred_bearing(
pipe_bind_plane(stream, session).await
}
// UDP ASSOCIATE through the TCP door is the UDP door's shape
// (the datagram plane's RFC codec boundary — ADR-003), not
// this one's: refuse RFC-faithfully — the vanilla client gets
// (the datagram plane's RFC codec boundary, ADR-003), not
// this one's: refuse RFC-faithfully, so the vanilla client
// gets
// the refused reply through the door's own codec (no phantom
// data plane, no in-band session opened).
Socks5Command::UDPAssociate => {
@@ -524,7 +531,7 @@ async fn cred_bearing(
}
}
/// The door's local RFC 1929 half (the forked surface — ADR-005's
/// The door's local RFC 1929 half (the forked surface, ADR-005's
/// accept_password_auth shape): `negotiate_auth(&[
/// PasswordAuthentication])` → `read_username_password` → check
/// through the static table → `accept`/`reject`, then
@@ -538,7 +545,8 @@ async fn local_rfc1929_half(
) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> {
// The check closure is sync (`FnMut(String, String) -> bool` over
// the forked `CheckResult`'s `bool` impl); the mapped identity is
// recorded through a sync witness (a `std` mutex — the closure
// recorded through a sync witness (a `std` mutex, since the
// closure
// runs once, synchronously).
let found: Arc<std::sync::Mutex<Option<Identity>>> = Arc::new(std::sync::Mutex::new(None));
let found_for_check = Arc::clone(&found);
@@ -547,7 +555,7 @@ async fn local_rfc1929_half(
stream,
|user, pass| {
let mapped = table.lookup(&user, &pass).cloned();
// A poisoned lock would mean a panic mid-check — the
// A poisoned lock would mean a panic mid-check; the
// `unwrap_or_else(into_inner)` convention keeps the door
// alive (AGENTS.md convention 2).
let mut slot = found_for_check.lock().unwrap_or_else(|e| e.into_inner());
@@ -573,22 +581,23 @@ async fn local_rfc1929_half(
}
/// Pipe the re-originated CONNECT session: the vanilla reply (the
/// in-band reply's BND.ADDR re-encoded — the RFC codec at the door
/// in-band reply's BND.ADDR re-encoded (the RFC codec at the door
/// boundary only, ADR-003), then the data plane ↔ the accepted socket.
/// The door already consumed the vanilla client's greeting + command
/// bytes; the post-auth bytes ride the session's data plane raw from
/// here. The pump shape is the taken inner stream
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite` — the
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite`, the
/// forked client type's intended use) against the local socket via
/// `copy_bidirectional`; the session stays the channel-teardown owner
/// (the `into_inner` pairing; dropped at the end — the EOF sentinel
/// (the `into_inner` pairing; dropped at the end, so the EOF
/// sentinel
/// reaches the producer's pump).
async fn pipe_connect_plane(
mut stream: TcpStream,
session: crate::consumer::Socks5ChannelSession,
) -> DoorExit {
// The vanilla CONNECT reply: the in-band reply's BND.ADDR
// re-encoded (the vanilla client is waiting for it — the door's
// re-encoded (the vanilla client is waiting for it; the door's
// RFC half consumed its local bytes; the in-band conversation is
// fresh and re-originated). The sentinel address is the
// RFC-compatibility surface the producer already chose; the door
@@ -621,7 +630,7 @@ async fn pipe_connect_plane(
/// Pipe the re-originated BIND session (the BIND door, cred-bearing
/// variant): reply#1's advertised address is re-encoded to the
/// vanilla client from the session's captured `bind_addr` (the
/// conversation is the vanilla client's own — the door's RFC half
/// conversation is the vanilla client's own, and the door's RFC half
/// consumed the local greeting + command bytes, and the in-band
/// session's reply#1 address passes through re-encoded; consumer.md
/// §The BIND door's reply#2 fidelity). The quiet wait rides
@@ -633,7 +642,8 @@ async fn pipe_bind_plane(
mut session: crate::consumer::BindSession,
) -> DoorExit {
// Reply#1 (the vanilla form): the in-band reply's BND.ADDR
// re-encoded — the advertised address passes through unchanged.
// re-encoded, and the advertised address passes through
// unchanged.
let bind_addr = session.reply1().map(|reply1| reply1.bind_addr.clone());
let Some(bind_addr) = bind_addr else {
session.close().await;
@@ -651,7 +661,7 @@ async fn pipe_bind_plane(
/// Reply#1's vanilla wire form, re-encoded from the in-band reply's
/// advertised address: an IP BND.ADDR rides the codec (`new_reply`);
/// a Domain BND.ADDR is the hand-written domain frame (the forked
/// codec's `new_reply` takes a `SocketAddr` only — the domain form
/// codec's `new_reply` takes a `SocketAddr` only; the domain form
/// has no codec path, so the frame is assembled here; the byte shape
/// is pinned by the unit table, review 003 N-8).
fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
@@ -673,10 +683,10 @@ fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
}
/// The BIND door's quiet wait + reply#2 + the data plane: await the
/// inbound accept through the session (`await_inbound` — the one
/// inbound accept through the session (`await_inbound`, the one
/// normative reply#2 reader), re-encode reply#2's peer to the vanilla
/// client, then pipe the pass-through plane raw (the plane owns the
/// in-band stream — `BindDataPlane` impls `AsyncRead + AsyncWrite`,
/// in-band stream: `BindDataPlane` impls `AsyncRead + AsyncWrite` for
/// the pass-through to the accepted peer; the session stays the
/// teardown owner, dropped at the end).
async fn bind_quiet_and_pipe(
@@ -688,9 +698,9 @@ async fn bind_quiet_and_pipe(
Ok(None) => return DoorExit::PlaneEnded,
// An accept-stage refusal (the mapped reply#2 code): the
// vanilla client gets that code re-encoded door-side (the RFC
// codec at the boundary — never a bare EOF), then the
// connection closes — the conversation ends where the in-band
// one did.
// codec at the boundary, never a bare EOF), then the
// connection closes, and the conversation ends where the
// in-band one did.
Err(err) => {
write_refusal_reply(&mut stream, &err).await;
session.close().await;
@@ -717,7 +727,7 @@ async fn bind_quiet_and_pipe(
}
/// Write one vanilla reply frame (the door's RFC codec at the
/// boundary — ADR-003): write + flush; a failed write is the client
/// boundary, ADR-003): write + flush; a failed write is the client
/// gone.
async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> {
stream.write_all(reply).await?;
@@ -727,7 +737,7 @@ async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::R
/// Re-encode an in-band session error's refusal to the vanilla client
/// (the door's RFC codec at the boundary): an error carrying an RFC
/// reply code gets that code as the command's reply (`Succeeded`-form
/// encoding with the error's code + the placeholder BND.ADDR — a
/// encoding with the error's code + the placeholder BND.ADDR, since a
/// refused reply carries a zero address per the RFC); any other error
/// shape (open/transport/I-O) stays a bare close (nothing to encode).
/// Best-effort: a failed write means the client is gone either way.
@@ -741,7 +751,7 @@ async fn write_refusal_reply(stream: &mut TcpStream, err: &SocksSessionError) {
}
/// The UDP front door (feature `local`, the POC #2 test-8 shape): the
/// ASSOCIATE door — a real UDP relay socket bridged onto one in-band
/// ASSOCIATE door: a real UDP relay socket bridged onto one in-band
/// association. See the module docs for the shape and the codec
/// boundary (ADR-003).
pub struct UdpFrontDoor {
@@ -753,7 +763,7 @@ pub struct UdpFrontDoor {
/// door's [`DoorExit`], for the datagram plane).
#[derive(Debug)]
enum UdpDoorExit {
/// The in-band association ended (channel EOF — the association
/// The in-band association ended (channel EOF; the association
/// lifetime is the channel's, ADR-003).
AssociationEnded,
/// The client's idle bound fired (ADR-014's door-side defense).
@@ -783,7 +793,7 @@ impl UdpFrontDoor {
}
/// The builder for [`UdpFrontDoor`]. The bind address is required
/// (the explicit bind decision — never implied); the idle bound is
/// (the explicit bind decision, never implied); the idle bound is
/// the ADR-014 door-side defense knob.
#[derive(Default)]
pub struct UdpFrontDoorBuilder {
@@ -792,7 +802,7 @@ pub struct UdpFrontDoorBuilder {
}
impl UdpFrontDoorBuilder {
/// The local bind address — the relay socket's explicit bind
/// The local bind address, the relay socket's explicit bind
/// decision (loopback is the documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into());
@@ -820,7 +830,7 @@ impl UdpFrontDoor {
/// Bind the configured relay address and serve the association
/// over it. Runs until the association ends (the in-band channel
/// EOF, the client idle bound, or a fatal I/O failure); the relay
/// socket closes on return — the association lifetime is the
/// socket closes on return, so the association lifetime is the
/// channel's, and the door's socket closes with it.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let socket = UdpSocket::bind(self.bind_addr).await?;
@@ -829,17 +839,17 @@ impl UdpFrontDoor {
}
/// Serve the association over an existing relay socket (the
/// test/assembly seam — the same pump without the bind).
/// test/assembly seam, the same pump without the bind).
///
/// Posture (the peer-pin shape, OQ-SK2-09): the client's source
/// **IP** is learned from the first inbound datagram and pinned for
/// the association's lifetime; replies go to the **latest-seen
/// port** from that IP (a NAT port rebind under a stable IP keeps
/// receiving). Datagrams from a source IP other than the pinned
/// one are dropped — RFC 1928's MUST ("MUST drop any datagrams
/// one are dropped, per RFC 1928's MUST ("MUST drop any datagrams
/// arriving from any source IP address other than the one recorded
/// for the particular association"). The residual (a full client
/// IP change mid-association — a NAT *address* rebind) stops
/// IP change mid-association (a NAT *address* rebind) stops
/// receiving replies by design: the RFC's expected-peer posture,
/// revisitable per OQ-SK2-09. The loopback bind is the documented
/// posture; a wildcard-bind deployment accepts the same-IP
@@ -853,8 +863,8 @@ impl UdpFrontDoor {
let mut association = AssociateSession::associate(client.as_ref())
.await
.map_err(|err| std::io::Error::other(err.to_string()))?;
// The reply's sentinel BND.ADDR is received and never used —
// the handle is the channel (ADR-003). The real relay address
// The reply's sentinel BND.ADDR is received and never used,
// since the handle is the channel (ADR-003). The real relay address
// is this door's own bound socket; the vanilla client learns
// it from the TCP door's ASSOCIATE reply (assembly-layer
// wiring).
@@ -862,17 +872,18 @@ impl UdpFrontDoor {
// The datagram pump rides the association's split stream
// halves: one pump owns the in-band write half (socket→
// channel), the other the read half (channel→socket) — no
// shared association lock (a mutex over the whole session
// would deadlock: the channel read parks holding it while the
// socket side's send needs it). The pump is inlined (the
// channel), the other the read half (channel→socket), so no
// shared association lock holds (a mutex over the whole
// session would deadlock: the channel read parks holding it
// while the socket side's send needs it). The pump is inlined
// (the
// halves borrow the association; the borrow ends before the
// teardown below).
let Some((mut channel_read, mut channel_write)) = association.split() else {
return Ok(());
};
let socket = Arc::new(socket);
// The peer pin (OQ-SK2-09): the client's source IP — learned
// The peer pin (OQ-SK2-09): the client's source IP, learned
// from the first inbound datagram and pinned; the reply port
// refreshes to the latest-seen source port from that IP (the
// NAT-port-rebind shape). A source IP mismatch is the RFC's
@@ -883,9 +894,9 @@ impl UdpFrontDoor {
Arc::new(std::sync::RwLock::new(None));
// socket→channel: decode the RFC header (the codec boundary),
// refuse FRAG ≠ 0 (drop the datagram — the v1 posture), pin
// refuse FRAG ≠ 0 (drop the datagram, the v1 posture), pin
// the peer IP + enforce the RFC's same-IP drop rule (the pin
// reacts to *parsed* datagrams only — protocol garbage can
// reacts to *parsed* datagrams only, so protocol garbage can
// never move it), refresh the reply port, send `{addr, data}`
// in-band. Each datagram resets the idle window.
let s2c = {
@@ -944,8 +955,8 @@ impl UdpFrontDoor {
// latest-seen reply port. The association's end (the channel
// EOF) is this pump's clean exit. The decode state (the
// incremental reader + the batched-datagram pending queue)
// persists across pump iterations — the bridge owns it for the
// association's lifetime.
// persists across pump iterations, so the bridge owns it for
// the association's lifetime.
let c2s = {
let socket = Arc::clone(&socket);
let client_peer = Arc::clone(&client_peer);
@@ -984,7 +995,8 @@ impl UdpFrontDoor {
let _ = association.shutdown().await;
// A socket I/O failure is the door's fatal outcome (surfaced to
// the caller); the idle bound, the association's end, and a
// session error are the association's own ends — a clean return.
// session error are the association's own ends, so the return
// is clean.
match exit {
UdpDoorExit::Io(err) => Err(err),
_ => Ok(()),
@@ -992,7 +1004,7 @@ impl UdpFrontDoor {
}
}
/// The in-band frame send (the write half's shape — frame once with
/// The in-band frame send (the write half's shape: frame once with
/// the wire codec, write the framed bytes to the channel write half).
async fn send_inband(
write: &mut tokio::io::WriteHalf<&mut AssociationStream>,
@@ -1006,11 +1018,12 @@ async fn send_inband(
}
/// The in-band frame read (the read half's shape): a fresh-looking
/// async read whose decoder state **persists across calls** — the
/// async read whose decoder state **persists across calls**: the
/// bridge's [`InbandBridge`] owns the incremental `DatagramReader`
/// and the batched-datagram pending queue across calls (a datagram
/// split across channel chunks reassembles across reads; frames
/// decoded beyond the one returned queue for later reads — the POC
/// decoded beyond the one returned queue for later reads; this is
/// the POC
/// #2 reader-state finding, the same shape
/// `AssociateSession::recv_datagram` owns).
struct InbandBridge {
@@ -1027,7 +1040,7 @@ impl InbandBridge {
}
/// The next in-band datagram: the pending queue first (a chunk
/// may have batched several frames — drain in order), then an
/// may have batched several frames, drained in order), then an
/// incremental decode off the read half. `Ok(None)` on channel
/// EOF (the association's end); fail-loud mid-frame (never a
/// silent partial datagram).
@@ -1071,7 +1084,7 @@ impl InbandBridge {
}
/// The channel→socket pump's decode call (the bridge the pump loop
/// drives — the decode state lives in the bridge, not per call).
/// drives, so the decode state lives in the bridge, not per call).
async fn recv_inband<R>(
bridge: &mut InbandBridge,
read: &mut R,
@@ -1083,7 +1096,8 @@ where
}
/// Map a wire-decode error into the RFC vocabulary (the consumer
/// module's mapping, re-declared here — the private consumer helper is
/// module's mapping, re-declared here, since the private consumer
/// helper is
/// not part of the public surface).
fn wire_error(err: WireError) -> SocksSessionError {
match err {
@@ -1095,7 +1109,7 @@ fn wire_error(err: WireError) -> SocksSessionError {
}
}
/// The association's framed stream type (the split halves' target —
/// The association's framed stream type (the split halves' target,
/// the forked client type over the channel's `BiStream`).
type AssociationStream = crate::rfc1928::client::Socks5Stream<alkcall::core::types::BiStream>;
@@ -1108,7 +1122,7 @@ mod tests {
/// Drive one pump-shaped decode call: feed `bytes` from the peer
/// side, then decode (the recv parks until a frame completes or
/// the stream ends — the pump loop's shape).
/// the stream ends, matching the pump loop's shape).
async fn feed_then_recv<R>(
bridge: &mut InbandBridge,
read: &mut R,
@@ -1129,7 +1143,7 @@ mod tests {
async fn batched_frames_queue_not_drop() {
// Two frames batched into one feed: the first returns, the
// second queues and drains on the next call (the per-call
// reader dropped it — review 003 R-1; the pending queue is
// reader dropped it, review 003 R-1; the pending queue is
// the state that persists across the call boundary).
let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1153,7 +1167,8 @@ mod tests {
#[tokio::test]
async fn eof_mid_frame_fails_loud_not_silent() {
// A frame in flight when the stream ends: the decode errors
// loud (never a silent partial datagram) — the same shape
// loud (never a silent partial datagram), matching the same
// shape
// AssociateSession::recv_datagram pins.
let (mut writer, reader) = tokio::io::duplex(1024);
let (mut channel_read, _channel_write) = tokio::io::split(reader);
@@ -1183,7 +1198,7 @@ mod tests {
async fn partial_frame_state_persists_across_calls() {
// One chunk carries frame 1 complete + the first bytes of
// frame 2: call 1 returns frame 1 (frame 2's bytes stay in
// the bridge's reader), call 2 — after the stream ends —
// the bridge's reader), and call 2 (after the stream ends)
// fails loud on the surviving mid-frame state (a per-call
// reader would report a clean Ok(None) EOF here).
let (mut writer, reader) = tokio::io::duplex(1024);
@@ -1222,8 +1237,8 @@ mod refusal_and_wire_unit_tests {
use alkcall::protocol::wire::CallError;
use tokio::io::AsyncReadExt;
/// A connected TcpStream pair (the loopback listener shape —
/// `TcpStream` has no `pair` constructor; the listener dial is
/// A connected TcpStream pair (the loopback listener shape;
/// `TcpStream` has no `pair` constructor, so the listener dial is
/// the portable way).
async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -1238,7 +1253,7 @@ mod refusal_and_wire_unit_tests {
/// The BIND reply#1 wire table (review 003 N-8: the Domain arm was
/// hand-written wire code with no test): the IP form decodes
/// through the forked codec (`new_reply`'s own table covers it);
/// the Domain form's bytes are pinned exactly — VER, REP, RSV,
/// the Domain form's bytes are pinned exactly (VER, REP, RSV,
/// ATYP=domain, len, name, port.
#[test]
fn bind_reply1_wire_forms() {
@@ -1267,7 +1282,7 @@ mod refusal_and_wire_unit_tests {
}
/// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an
/// open/transport-shaped session error stays a bare close — no
/// open/transport-shaped session error stays a bare close, so no
/// bytes are written to the vanilla client.
#[tokio::test]
async fn write_refusal_reply_skips_non_reply_code_shapes() {
@@ -1278,8 +1293,8 @@ mod refusal_and_wire_unit_tests {
}));
write_refusal_reply(&mut server, &open_err).await;
// Nothing written: the client's read finds no bytes (the bare
// close is the door's caller's act — the helper itself is
// silent). A short window proves no reply bytes exist.
// close is the door's caller's act, since the helper itself
// is silent). A short window proves no reply bytes exist.
let mut probe = [0u8; 16];
let n = tokio::time::timeout(
std::time::Duration::from_millis(200),
@@ -1323,7 +1338,8 @@ mod bridge_later_batch_tests {
use tokio::io::AsyncWriteExt;
/// A decode failure in a *later* batch frame (review 003 N-8's
/// residual: the unit tests pinned the happy shapes — a garbage
/// residual: the unit tests pinned the happy shapes, where a
/// garbage
/// frame following a good one in the same chunk surfaces the
/// error, the good frame is NOT silently delivered first).
#[tokio::test]
@@ -1333,7 +1349,7 @@ mod bridge_later_batch_tests {
let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr"));
let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec();
// A later frame whose length field promises more bytes than
// the chunk carries, then the stream ends — the reader's
// the chunk carries, then the stream ends, so the reader's
// state machine fails loud on the truncated later frame.
wire.extend_from_slice(&[0xff, 0xff]);
wire.extend_from_slice(b"short");
+48 -47
View File
@@ -1,12 +1,13 @@
//! The three I/O-plane seam types (ADR-004) — the dial seam (CONNECT),
//! the egress seam (UDP ASSOCIATE), the accept seam (TCP BIND) — as
//! function-shaped callbacks with their error vocabularies. These types
//! are the inversion point (AGENTS.md convention 7): the protocol layer
//! The three I/O-plane seam types (ADR-004), each a function-shaped
//! callback with its error vocabulary: the dial seam (CONNECT), the
//! egress seam (UDP ASSOCIATE), and the accept seam (TCP BIND). These
//! types are the inversion point (AGENTS.md convention 7): the
//! protocol layer
//! never names a substrate type; the feature-gated providers
//! (`local`/`composed`, batch 2) and the test harness construct them.
//!
//! **Function, not trait** (ADR-004): [`DialFn`], [`EgressFactory`],
//! and [`BindAcceptFactory`] are function-shaped callbacks — "produce
//! and [`BindAcceptFactory`] are function-shaped callbacks: "produce
//! boxed halves for a resource" is a function, not a trait (the
//! alktunnels ADR-004 finding, extended to all three SOCKS5 seams by
//! POC #1/#2/#5). [`Egress`] is the one trait: the per-association
@@ -19,29 +20,29 @@
//! - **Ownership rides the handle structurally** ([`TargetHandle`],
//! [`BindTargetHandle`]): the payloads own their halves; any
//! keep-alive state a substrate needs (e.g. a composed variant's
//! channel session) rides inside the halves — a provider-side owner
//! wrapper — so dropping the handle drops everything it dialed or
//! channel session) rides inside the halves (a provider-side owner
//! wrapper), so dropping the handle drops everything it dialed or
//! accepted. No `_` bindings of owning values: the
//! teardown-at-birth discipline, compiler-enforced by the shape.
//! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns `addr + data` — the
//! payload only; [`Egress::next_reply`] returns `addr + data`; the
//! frame codec lives on the wire side, applied exactly once
//! ([`crate::wire`]).
//! - **The readiness contract** ([`Egress::next_reply`]): `Pending`
//! when no reply is ready — an instantly-ready `Ok(None)` starves a
//! when no reply is ready; an instantly-ready `Ok(None)` starves a
//! biased `select!` into livelock (POC #2's structural finding).
//! - **The accept future owns its resources end-to-end**
//! ([`BindAccept`]): the quiet wait's `select!` drop IS the
//! client-gone teardown (ADR-004/ADR-007).
//! - **Refusal is RFC-faithful**: every error type maps to RFC 1928
//! reply codes via `to_reply_error()`; a dial/egress/accept failure
//! is reply-code-shaped (never coerced to a channels-level error —
//! is reply-code-shaped (never coerced to a channels-level error;
//! session-level refusal stays channels-level, target-level refusal
//! is in-stream).
//!
//! The readiness-contract behavioral tests (an instant-`Ok(None)`
//! egress must not starve the datagram stage — including the
//! zero-yield shape, the gate review's R-1,
//! egress must not starve the datagram stage, including the
//! zero-yield shape (the gate review's R-1,
//! tasks/implementation/review-protocol-core.md) live in the producer
//! module's test suite, next to the stage they pin. This module
//! carries the error→reply-code mapping tables, the sync-lift factory
@@ -58,7 +59,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::ReplyError;
/// The dialed CONNECT target — boxed halves, `+ Sync` per the
/// The dialed CONNECT target: boxed halves, `+ Sync` per the
/// `ChannelPlan` payload bound (ADR-004). Ownership rides the handle
/// structurally: the payloads own their connection halves; keep-alive
/// state a substrate needs must ride inside the halves (a provider-side
@@ -78,16 +79,16 @@ impl std::fmt::Debug for TargetHandle {
/// The dial seam (ADR-004): the substrate dial function, injected at
/// registration/assembly. Called once per CONNECT at the command-read
/// interception point with the RFC 1928 target the client requested
/// (domain form passes through unresolved — ADR-012; resolution is the
/// provider's job). Per-target refusal is the returned
/// [`SocksDialError`] (the producer replies with the mapped RFC code —
/// no phantom data plane); session-level refusal stays channels-level.
/// (domain form passes through unresolved, per ADR-012; resolution is
/// the provider's job). Per-target refusal is the returned
/// [`SocksDialError`] (the producer replies with the mapped RFC code,
/// so no phantom data plane); session-level refusal stays channels-level.
pub type DialFn = Arc<
dyn Fn(TargetAddr) -> BoxFuture<'static, Result<TargetHandle, SocksDialError>> + Send + Sync,
>;
/// A dial failure, mapped to RFC 1928 reply codes by
/// [`SocksDialError::to_reply_error`] (never a generic error —
/// [`SocksDialError::to_reply_error`] (never a generic error;
/// AGENTS.md convention 2's faithful `ReplyError` mapping).
#[derive(Debug, thiserror::Error)]
pub enum SocksDialError {
@@ -109,7 +110,7 @@ impl SocksDialError {
/// `NetworkUnreachable` → `NetworkUnreachable` (0x03),
/// `HostUnreachable` → `HostUnreachable` (0x04), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #1 mapping, table-tested in this module.
/// (0x01); the POC #1 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -122,9 +123,9 @@ impl SocksDialError {
}
/// One association's egress failure. Mapped to RFC reply codes only at
/// association setup ([`EgressError::to_reply_error`] — an egress that
/// association setup ([`EgressError::to_reply_error`]; an egress that
/// cannot start refuses the ASSOCIATE pre-reply); per-datagram failures
/// are not per-datagram refusals — the producer stage ends the
/// are not per-datagram refusals, so the producer stage ends the
/// association on an `Err` from the seam (the lossy-datagram posture).
#[derive(Debug, thiserror::Error)]
pub enum EgressError {
@@ -140,7 +141,7 @@ impl EgressError {
/// The RFC 1928 reply code for the ASSOCIATE refusal: `Refused` →
/// `ConnectionRefused` (0x05), `Unreachable` →
/// `NetworkUnreachable` (0x03), `General` → `GeneralFailure`
/// (0x01) — the POC #2 mapping, table-tested in this module.
/// (0x01), the POC #2 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -150,26 +151,26 @@ impl EgressError {
}
}
/// The per-association datagram egress — the ASSOCIATE seam's returned
/// The per-association datagram egress, the ASSOCIATE seam's returned
/// object and the module's one trait (ADR-004: the factory is
/// function-shaped; the instance it returns is a trait object,
/// `Box<dyn Egress + Send>`, owned by the producer's datagram stage —
/// `Box<dyn Egress + Send>`, owned by the producer's datagram stage;
/// stage exit drops it, so egress state never outlives the channel).
///
/// The stage never sees which substrate backs the association
/// (convention 7 at the egress layer).
pub trait Egress {
/// Egress one datagram to `addr`. Payload-only: the producer stage
/// decoded the wire frame and hands the bare datagram here — the
/// decoded the wire frame and hands the bare datagram here, and the
/// egress never sees a length prefix (framing is applied exactly
/// once, by the wire layer). A zero-length payload is a legal
/// empty datagram, not a signal of any kind (the F-2 invariant).
fn send(&mut self, addr: TargetAddr, payload: Bytes) -> BoxFuture<'_, Result<(), EgressError>>;
/// Yield the next inbound datagram's *inner* bytes (`addr + data`
/// — the RFC address encoding followed by the payload, exactly
/// Yield the next inbound datagram's *inner* bytes (`addr + data`,
/// the RFC address encoding followed by the payload, exactly
/// [`TargetAddr::to_be_bytes()`] + data). The length prefix is the
/// wire layer's, added by the producer's frame writer — an egress
/// wire layer's, added by the producer's frame writer; an egress
/// that prefixes again is the double-framing bug class POC #2
/// caught.
///
@@ -181,14 +182,14 @@ pub trait Egress {
/// and a hot `Ok(None)` never yields to the channel branch (nor to
/// the runtime's other tasks on a current-thread scheduler).
/// `Ok(None)` is tolerated as a spurious wakeup (the stage
/// re-parks), not as a readiness signal — implement the wait as a
/// re-parks), not as a readiness signal; implement the wait as a
/// real async park. An `Err` ends the association (the stage
/// returns; the egress drops with it).
fn next_reply(&mut self) -> BoxFuture<'_, Result<Option<Bytes>, EgressError>>;
}
/// The egress seam (ADR-004): the per-association egress factory — the
/// [`DialFn`] analogue for the datagram stage, injected at
/// The egress seam (ADR-004): the per-association egress factory (the
/// [`DialFn`] analogue for the datagram stage), injected at
/// registration/assembly. Called once per UDP ASSOCIATE; the stage owns
/// the returned egress; stage exit drops it.
pub type EgressFactory =
@@ -208,16 +209,16 @@ where
})
}
/// The one accepted inbound connection's handle — reply#2's BND.ADDR
/// The one accepted inbound connection's handle, reply#2's BND.ADDR
/// carrier plus the data-plane halves. Ownership rides the handle
/// structurally: the payloads own their accepted halves; keep-alive
/// state a substrate needs (e.g. a composed variant's channel session)
/// rides inside the halves (a provider-side owner wrapper), so the
/// compiler enforces the teardown contract — no `_` bindings of owning
/// values (the third teardown-at-birth witness, ADR-004).
/// compiler enforces the teardown contract, with no `_` bindings of
/// owning values (the third teardown-at-birth witness, ADR-004).
pub struct BindTargetHandle {
/// The accepted connection's peer address (reply#2's BND.ADDR —
/// the RFC's "client IP and port of the incoming connection").
/// The accepted connection's peer address (reply#2's BND.ADDR, the
/// RFC's "client IP and port of the incoming connection").
pub peer: TargetAddr,
pub read: Box<dyn AsyncRead + Send + Sync + Unpin>,
pub write: Box<dyn AsyncWrite + Send + Sync + Unpin>,
@@ -232,14 +233,14 @@ impl std::fmt::Debug for BindTargetHandle {
}
/// One BIND session's accept source. `bind_addr` is the address
/// reply#1 advertises (BND.ADDR — the real bound listener on the local
/// reply#1 advertises (BND.ADDR: the real bound listener on the local
/// variant, the advertised address on the composed one); the accept
/// future is awaited exactly once, in the producer's quiet-wait phase.
///
/// **The accept future owns its resources end-to-end** (ADR-004/
/// ADR-007): whatever the provider opened (the listener, the tunnel)
/// lives inside the future, so the quiet wait's `select!` drop of that
/// future IS the client-gone teardown — the listener closes when the
/// future IS the client-gone teardown; the listener closes when the
/// client vanishes, with no extra teardown path.
pub struct BindAccept {
bind_addr: SocketAddr,
@@ -248,7 +249,7 @@ pub struct BindAccept {
impl BindAccept {
/// Build the session's accept source: `bind_addr` for reply#1, the
/// one-inbound-accept future (owned end-to-end — see the type
/// one-inbound-accept future (owned end-to-end; see the type
/// docs).
pub fn new(
bind_addr: SocketAddr,
@@ -265,7 +266,7 @@ impl BindAccept {
self.bind_addr
}
/// Await the one inbound accept (the quiet wait's accept branch —
/// Await the one inbound accept (the quiet wait's accept branch,
/// exactly once; the future is consumed).
pub async fn accept(self) -> Result<BindTargetHandle, BindAcceptError> {
self.accept.await
@@ -281,7 +282,7 @@ impl std::fmt::Debug for BindAccept {
}
/// Accept-stage failures (post-reply#1): the producer maps them to RFC
/// reply codes for reply#2 — no data plane on refusal.
/// reply codes for reply#2, and no data plane exists on refusal.
#[derive(Debug, thiserror::Error)]
pub enum BindAcceptError {
#[error("accept refused: {0}")]
@@ -296,7 +297,7 @@ impl BindAcceptError {
/// The RFC 1928 reply code for reply#2 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `General` → `GeneralFailure`
/// (0x01) — the POC #5 mapping, table-tested in this module.
/// (0x01), the POC #5 mapping, table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
Self::Refused(_) => ReplyError::ConnectionRefused,
@@ -307,7 +308,7 @@ impl BindAcceptError {
}
/// Factory failures (pre-reply#1): the producer maps them to RFC reply
/// codes for reply#1 — no phantom listener.
/// codes for reply#1, with no phantom listener.
#[derive(Debug, thiserror::Error)]
pub enum BindAcceptFactoryError {
#[error("bind refused: {0}")]
@@ -324,8 +325,8 @@ impl BindAcceptFactoryError {
/// The RFC 1928 reply code for reply#1 on this refusal: `Refused` →
/// `ConnectionRefused` (0x05), `NotAllowed` →
/// `ConnectionNotAllowed` (0x02), `Shortage`/`General` →
/// `GeneralFailure` (0x01 — the RFC's catch-all; a resource
/// shortage has no dedicated code) — the POC #5 mapping,
/// `GeneralFailure` (0x01; the RFC's catch-all, since a resource
/// shortage has no dedicated code), the POC #5 mapping,
/// table-tested in this module.
pub fn to_reply_error(&self) -> ReplyError {
match self {
@@ -337,12 +338,12 @@ impl BindAcceptFactoryError {
}
}
/// The accept seam (ADR-004): the per-BIND accept factory — the
/// The accept seam (ADR-004): the per-BIND accept factory, the
/// [`DialFn`]'s mirror, injected at registration/assembly. Called once
/// per TCP BIND with the requested target. The requested port is the
/// only field a real bind can honor (domains are meaningless as bind
/// addresses); the address policy is the factory's (ADR-006/ADR-012:
/// the callbacks refuse — RFC reply codes).
/// the callbacks refuse, with RFC reply codes).
///
/// Failure mapping is two-stage (ADR-007): factory failure
/// ([`BindAcceptFactoryError`]) → reply#1 with the mapped code;
+12 -12
View File
@@ -5,10 +5,10 @@
//! [`SocksServerError`], [`ReplyError`]).
//!
//! Open errors are channels-level: a failed open never yields a
//! phantom channel (the no-phantom-channel property, ADR-049 — the
//! phantom channel (the no-phantom-channel property, ADR-049: the
//! session never exists opener-side). Target-level refusal happens
//! later, in-stream, as an RFC reply code surfaced through
//! [`SocksSessionError::Rfc`] (consumer.md §Error surface) — never as
//! [`SocksSessionError::Rfc`] (consumer.md §Error surface), never as
//! an open failure.
use alkcall::channels::client::ChannelOpenError;
@@ -17,7 +17,7 @@ pub use crate::rfc1928::server::SocksServerError;
pub use crate::rfc1928::{ReplyError, SocksError};
/// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed
/// SOCKS5 channel open. A failure never yields a session — no phantom
/// SOCKS5 channel open. A failure never yields a session: no phantom
/// channel (the channel is torn down accept-side and no `channel_id`
/// was ever returned).
///
@@ -33,7 +33,7 @@ pub use crate::rfc1928::{ReplyError, SocksError};
/// [`ChannelOpenError`].
#[derive(Debug, thiserror::Error)]
pub enum SocksOpenError {
/// The open op failed — the typed [`ChannelOpenError`] carries the
/// The open op failed; the typed [`ChannelOpenError`] carries the
/// wire [`alkcall::protocol::wire::CallError`] verbatim
/// (`channel:open_failed` + `details.reason` per ADR-049 §3 when
/// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`,
@@ -52,7 +52,7 @@ impl SocksOpenError {
/// The typed establishment-failure reason of a
/// `channel:open_failed` error (`dial_failed` / `unknown_resource`
/// / `resource_shortage` / `handler_error` / `timeout` — alkcall
/// / `resource_shortage` / `handler_error` / `timeout`; alkcall
/// ADR-049 §3's vocabulary, parsed). `None` for any other failure
/// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes,
/// `channel:too_many_channels`, or local-only failures
@@ -65,7 +65,7 @@ impl SocksOpenError {
/// The typed establishment-reason vocabulary (alkcall ADR-049 §3's
/// reason codes, parsed from the wire string). One-way once consumers
/// branch on it (ADR-049's door-type note) — the string codes are the
/// branch on it (ADR-049's door-type note): the string codes are the
/// wire truth; this enum is the typed view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocksEstablishmentReason {
@@ -83,7 +83,7 @@ pub enum SocksEstablishmentReason {
impl SocksEstablishmentReason {
/// Parse the wire `details.reason` string (ADR-049 §3's
/// vocabulary). Unknown strings are `None` — a *new* reason code
/// vocabulary). Unknown strings are `None`: a *new* reason code
/// from a newer producer must not silently alias an old variant
/// (the vocabulary is one-way; additive codes need a migration).
pub fn parse(reason: &str) -> Option<Self> {
@@ -117,8 +117,8 @@ impl std::fmt::Display for SocksEstablishmentReason {
/// The consumer session's error surface (consumer.md §Error surface):
/// open failures are channels-level and typed
/// ([`SocksOpenError`]); in-stream RFC failures (a refused target —
/// the reply code) are [`SocksSessionError::Rfc`]; the data-plane
/// ([`SocksOpenError`]); in-stream RFC failures (the refused-target
/// reply code) are [`SocksSessionError::Rfc`]; the data-plane
/// codec failures are [`SocksSessionError::Rfc`]'s inner
/// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O
/// failure.
@@ -131,7 +131,7 @@ pub enum SocksSessionError {
/// The channel open failed (the session never came to exist).
#[error("socks5 open failed: {0}")]
Open(#[from] SocksOpenError),
/// The in-stream RFC 1928 conversation failed — a refused target
/// The in-stream RFC 1928 conversation failed: a refused target
/// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND
/// without `socks5:bind`), an auth rejection, a malformed
/// negotiation, or the UDP-frame codec. The RFC-faithful surface:
@@ -180,7 +180,7 @@ mod tests {
#[test]
fn open_error_non_establishment_shapes_are_none() {
// The registry's own gate classes run before the establisher —
// The registry's own gate classes run before the establisher;
// they are NOT establishment reasons (ADR-049 §3).
for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] {
let err = SocksOpenError::Open(ChannelOpenError::CallFailed {
@@ -215,7 +215,7 @@ mod tests {
// Open plane: a refused session never yields a phantom channel.
let open = SocksSessionError::from(open_failed_with("dial_failed"));
assert!(matches!(open, SocksSessionError::Open(_)));
// In-stream RFC refusal — the reply code rides the session type
// In-stream RFC refusal: the reply code rides the session type
// (consumer.md §Error surface), never an open failure.
let refused =
SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed));
+15 -15
View File
@@ -1,4 +1,4 @@
//! alksocks — SOCKS5 (RFC 1928) as a produced resource on alkcall
//! alksocks: SOCKS5 (RFC 1928) as a produced resource on alkcall
//! channels.
//!
//! A producer/consumer protocol crate (the alktty/alktunnels shape):
@@ -6,7 +6,7 @@
//! drives the RFC 1928 conversation in-stream over the channel's
//! `BiStream`; the consumer half opens SOCKS5 channels and speaks the
//! protocol inside them. The defining posture: the protocol never
//! binds a port — kernel binds exist only as explicit, feature-gated
//! binds a port; kernel binds exist only as explicit, feature-gated
//! assembly shapes (`local`), never in the protocol layer.
//!
//! ## The landed surface (the protocol core)
@@ -14,18 +14,18 @@
//! - **The wire vocabulary and the explicit typestate** live in
//! [`rfc1928`]: a targeted fork of fast-socks5 v1.0.0's `T`-generic
//! subset (MIT, third-party upstream) carrying two deliberate
//! changes (BIND support, `new_reply` publication) — see
//! changes (BIND support, `new_reply` publication); see
//! `docs/architecture/rfc1928.md`, ADR-013, and ADR-011.
//! - **The wire codec** ([`wire`]): the ASSOCIATE datagram frame
//! (`[len: u16 BE][addr + data]`, ADR-003) — framing is applied
//! (`[len: u16 BE][addr + data]`, ADR-003): framing is applied
//! exactly once, by this layer; the egress speaks inner bytes.
//! - **The wire-stable open-op surface** ([`params`]): the
//! `channels/socks5/sub` op id, the `alk/socks5` ALPN, the
//! trivial `{}` params (ADR-001), and the two scope constants
//! (`socks5:open`, `socks5:bind` — two grants, never implied,
//! (`socks5:open`, `socks5:bind`; two grants, never implied,
//! ADR-006). The typed open-error vocabulary lives in [`error`].
//! - **The three seams** ([`egress`]): `DialFn` / `EgressFactory` /
//! `BindAcceptFactory` (ADR-004) — functions, not traits, at the
//! `BindAcceptFactory` (ADR-004): functions, not traits, at the
//! three I/O planes; ownership rides the handle structurally; the
//! egress readiness contract (`next_reply` `Pending` when nothing
//! is ready) is load-bearing API.
@@ -34,25 +34,25 @@
//! ASSOCIATE datagram stage / BIND phase model), the scope gates
//! (the open gate `socks5:open` at the registry; the command gate
//! `socks5:bind` at BIND dispatch), and
//! [`register_socks5_openable`] — substrate-agnostic over the
//! [`register_socks5_openable`], substrate-agnostic over the
//! seams; never a bind in the protocol layer.
//! - **The consumer half** ([`consumer`]): the typed channels-native
//! sessions — [`Socks5ChannelSession`] (CONNECT),
//! sessions: [`Socks5ChannelSession`] (CONNECT),
//! [`AssociateSession`] (the framed datagram stage),
//! [`BindSession`] (the reply#1/reply#2 reader) — each owning its
//! [`BindSession`] (the reply#1/reply#2 reader), each owning its
//! channel's teardown; no `Clone`. The cred-bearing front door's
//! re-origination rides the token-carrying surface (`TokenClient`
//! + the `*_with_token` constructor twins).
//!
//! The assembly layer (feature-gated, ADR-008/009): `local` (the
//! socket-backed providers — `local::tcp_dial` / `local::udp_egress`
//! / `local::bind_accept` — plus the producer-side
//! `local::ProducerDoor`) and `door` (the consumer's front doors —
//! the NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
//! socket-backed providers, `local::tcp_dial` / `local::udp_egress`
//! / `local::bind_accept`, plus the producer-side
//! `local::ProducerDoor`) and `door` (the consumer's front doors: the
//! NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
//! `UdpFrontDoor` with the RFC codec at the door boundary only,
//! ADR-005/003/014) ride `local`; the composed providers
//! (`composed::direct_egress` — the alktunnels direct-op egress,
//! `composed::bindfirst_accept` — the bind-first accept,
//! (`composed::direct_egress`, the alktunnels direct-op egress;
//! `composed::bindfirst_accept`, the bind-first accept;
//! ADR-007/008) ride `composed`. The default crate is protocol-only
//! and wasm-clean by construction.
//!
+21 -21
View File
@@ -1,10 +1,10 @@
//! The `local` feature's socket-backed seam providers (ADR-008's local
//! variants, ADR-009's feature gate): the three seams' faithful-RFC
//! assembly-layer constructors — [`tcp_dial`] (CONNECT), [`udp_egress`]
//! assembly-layer constructors: [`tcp_dial`] (CONNECT), [`udp_egress`]
//! (UDP ASSOCIATE), [`bind_accept`] (TCP BIND). These are convenience
//! constructors over the landed seam types ([`crate::egress`]); the
//! protocol layer never sees them, and **the providers ARE the explicit
//! bind decisions** — the never-binds posture (convention 9) holds
//! bind decisions**; the never-binds posture (convention 9) holds
//! because the protocol layer never references this module: every bind
//! below (the dial's connect, the egress socket's `127.0.0.1:0`, the
//! BIND listener) is the caller's explicit choice to enable this
@@ -13,24 +13,24 @@
//! The seam contracts each constructor honors (the landed types'
//! normative doc comments, `crate::egress`):
//!
//! - **Ownership rides the handle structurally** — the payloads own
//! - **Ownership rides the handle structurally**: the payloads own
//! their socket halves; dropping the handle drops the connection.
//! - **The egress speaks inner bytes** — [`Egress::send`] takes the
//! - **The egress speaks inner bytes**: [`Egress::send`] takes the
//! payload only; [`Egress::next_reply`] returns
//! `TargetAddr::to_be_bytes() + data` (the *sender's* address, the
//! RFC's per-datagram source semantics); framing-once — the length
//! RFC's per-datagram source semantics); framing-once, so the length
//! prefix is the wire layer's ([`crate::wire`]), added exactly once
//! downstream.
//! - **The readiness contract** ([`Egress::next_reply`]) — parks on
//! - **The readiness contract** ([`Egress::next_reply`]): parks on
//! `readable()` (a real async wait on the socket's receive
//! readiness), then `try_recv_from`; `WouldBlock` → the
//! spurious-wakeup `Ok(None)` shape; never a hot `Ok(None)`.
//! - **The accept future owns its resources end-to-end** — the
//! - **The accept future owns its resources end-to-end**: the
//! listener lives inside the accept future; the quiet wait's drop IS
//! the client-gone teardown.
//!
//! Resolution is the provider's job (ADR-012): domain targets/destinations
//! resolve here, at the seam — the ssh `-D` semantic, via the
//! resolve here, at the seam (the ssh `-D` semantic), via the
//! non-blocking [`tokio::net::lookup_host`] resolver.
use std::io::ErrorKind;
@@ -49,9 +49,9 @@ use crate::{
};
/// Resolve a [`TargetAddr`] to a `SocketAddr` (egress-side DNS; the
/// ssh `-D` semantic — ADR-012: resolution is the provider's job). IP
/// ssh `-D` semantic; ADR-012: resolution is the provider's job). IP
/// targets resolve directly; domain targets resolve via
/// [`tokio::net::lookup_host`] (the non-blocking resolver — review 003
/// [`tokio::net::lookup_host`] (the non-blocking resolver; review 003
/// S-1's blocking-`to_socket_addrs` residue retired; the first address
/// wins, upstream parity).
pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, EgressError> {
@@ -71,7 +71,7 @@ pub(crate) async fn resolve_target(addr: &TargetAddr) -> Result<SocketAddr, Egre
}
/// Map an io error kind to the dial vocabulary (faithful-RFC mapping;
/// `NotAllowed` is never synthesized by this provider — it is a
/// `NotAllowed` is never synthesized by this provider, as it is a
/// ruleset concept, not an io one).
pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError {
match err.kind() {
@@ -89,7 +89,7 @@ pub(crate) fn dial_error_of(what: &str, err: &std::io::Error) -> SocksDialError
/// The CONNECT dial seam's local-socket variant (ADR-008): a real
/// `TcpStream` per CONNECT target; domain targets resolve here (the
/// ssh `-D` semantic). The connected stream splits into the
/// [`TargetHandle`] halves (`TcpStream::into_split` — the halves are
/// [`TargetHandle`] halves (`TcpStream::into_split`; the halves are
/// `Send + Sync + Unpin`, boxed per the seam's shape). Dial failures
/// map to [`SocksDialError`] faithfully ([`dial_error_of`]);
/// `NotAllowed` is never synthesized by this provider.
@@ -136,12 +136,12 @@ async fn local_tcp_dial(target: TargetAddr) -> Result<TargetHandle, SocksDialErr
///
/// **The readiness contract is load-bearing**: [`LocalEgress`]'s
/// `next_reply` parks on `sock.readable().await` (a real async park on
/// receive readiness — `writable()` is always ready for UDP and would
/// receive readiness, since `writable()` is always ready for UDP and would
/// spin), then `try_recv_from` (`WouldBlock` → the spurious-wakeup
/// `Ok(None)` shape; a datagram arriving makes the socket readable).
/// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data` —
/// Replies carry *inner* bytes (`TargetAddr::to_be_bytes() + data`,
/// the sender's address per the RFC's per-datagram source semantics;
/// framing-once — the length prefix is the wire layer's). `send` takes
/// framing-once: the length prefix is the wire layer's). `send` takes
/// the payload only.
pub fn udp_egress() -> EgressFactory {
Arc::new(|| {
@@ -161,7 +161,7 @@ pub struct LocalEgress {
impl LocalEgress {
/// Bind the association's socket (the explicit bind decision:
/// loopback ephemeral — a wildcard bind is the caller's variant
/// loopback ephemeral; a wildcard bind is the caller's variant
/// to add, never this constructor's default).
pub async fn bind() -> Result<Self, EgressError> {
let sock = UdpSocket::bind("127.0.0.1:0")
@@ -190,7 +190,7 @@ impl Egress for LocalEgress {
Box::pin(async move {
// A real async wait on the socket's receive readiness (a
// datagram arriving makes it readable): the stage's
// select! parks here with the channel read — no busy-loop,
// select! parks here with the channel read, so no busy-loop,
// no lost replies.
sock.readable()
.await
@@ -198,7 +198,7 @@ impl Egress for LocalEgress {
let mut scratch = vec![0u8; 65535];
match sock.try_recv_from(&mut scratch) {
Ok((n, from)) => {
// The INNER bytes (`addr + data` — the RFC's
// The INNER bytes (`addr + data`, the RFC's
// per-datagram source semantics): the length
// prefix is the wire layer's, applied exactly
// once downstream.
@@ -209,7 +209,7 @@ impl Egress for LocalEgress {
Ok(Some(Bytes::from(inner)))
}
// A spurious wakeup (the readiness contract's
// tolerated shape — the stage re-parks; this happens
// tolerated shape: the stage re-parks; this happens
// async, never a hot loop).
Err(e) if e.kind() == ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(EgressError::General(format!("udp try_recv: {e}"))),
@@ -223,7 +223,7 @@ impl Egress for LocalEgress {
/// session. The requested target's port is honored when non-zero (FTP
/// active mode); port 0 binds an ephemeral port; `local_addr()` is
/// reply#1's `bind_addr`. The accept future owns the listener
/// end-to-end (the quiet wait's drop IS the client-gone teardown — the
/// end-to-end (the quiet wait's drop IS the client-gone teardown, as
/// listener lives inside the future, never in a separate binding);
/// `accept()`'s peer is reply#2's [`BindTargetHandle::peer`]
/// (byte-exact). Factory failures map to [`BindAcceptFactoryError`]
@@ -231,7 +231,7 @@ impl Egress for LocalEgress {
/// accept-stage failures → [`BindAcceptError`].
///
/// **Address policy is the caller's**: `bind_host` is taken
/// explicitly — loopback (`127.0.0.1`) is the POC's posture; a
/// explicitly; loopback (`127.0.0.1`) is the POC's posture; a
/// wildcard bind is the caller's explicit choice, spelled here.
pub fn bind_accept(bind_host: impl Into<std::net::IpAddr>) -> BindAcceptFactory {
let bind_host = bind_host.into();
+33 -33
View File
@@ -1,41 +1,41 @@
//! The producer-side local backend (feature `local`; producer.md
//! §Producer-Side Local Backend): the same door shape in the other
//! direction — the producer serves from a real `TcpListener`, so
//! direction: the producer serves from a real `TcpListener`, so
//! vanilla clients reach a *locally-served* SOCKS5 resource (the
//! ssh `-D` capability hosted on the producer side). Each accepted
//! TCP connection is handed to the landed substrate-free entry
//! [`Socks5Producer::serve_socks5_over`] directly — the RFC
//! [`Socks5Producer::serve_socks5_over`] directly; the RFC
//! conversation runs on the accepted socket, the seams dial locally
//! (`tcp_dial()` / `udp_egress()` / `bind_accept()`). No new
//! protocol-layer code: this module is pure accept-loop assembly.
//!
//! **The door surface** (ADR-005): the NoAuth shape is the primary
//! one here — the producer IS the RFC conversation on this path
//! one here: the producer IS the RFC conversation on this path
//! (`serve_socks5_over` runs `accept_no_auth` in-stream); there is no
//! door-side protocol half to re-originate. A cred-bearing variant
//! (RFC 1929) would wrap `accept_password_auth` before
//! `serve_socks5_over` — documented follow-up, not landed (the task
//! `serve_socks5_over` remains a documented follow-up, not landed (the task
//! spec lands the NoAuth shape unless a test drives the other).
//!
//! **The accept path owns the idle defense + the session cap**
//! (ADR-014's accept-path defense; an accepted socket is not a
//! channel — the cap regime does not bound it): the idle-first-byte
//! peek (non-consuming — the producer's own greeting read picks the
//! channel; the cap regime does not bound it): the idle-first-byte
//! peek (non-consuming, as the producer's own greeting read picks the
//! byte up) and the max-concurrent-sessions cap (an accept beyond
//! the bound is refused door-side, the socket closed immediately, no
//! session spawned) are the same two knobs the consumer door carries
//! ([`crate::door::FrontDoor`]).
//!
//! **The BIND command gate** on this path is the builder's
//! `bind_policy` (the substrate-free entry's gate — ADR-006's
//! `bind_policy` (the substrate-free entry's gate; ADR-006's
//! posture). The pump handler's identity-derived gate does **not**
//! apply here: this path has no open-op identity — the accepted
//! apply here: this path has no open-op identity, and the accepted
//! socket is the whole caller; whatever policy governs it is the
//! builder's explicit `bind_policy(bool)` and the two accept-path
//! knobs.
//!
//! **Lifetime** (the alktunnels `TunnelListener` teardown posture):
//! the accept loop runs until the caller ends it — [`ProducerDoor`]
//! the accept loop runs until the caller ends it; [`ProducerDoor`]
//! hands back a guard whose `close()` awaits the loop's end; dropping
//! the guard is a best-effort abort (the spawned sessions own their
//! sockets; an in-flight session is not force-closed by a drop, only
@@ -51,13 +51,13 @@ use tokio::sync::{watch, Semaphore};
use crate::producer::Socks5Producer;
/// Why one accepted connection's serving task ended (the backend's
/// per-connection teardown diagnostic — logged + isolated; the loop
/// per-connection teardown diagnostic, logged + isolated; the loop
/// never exits on a per-connection failure).
#[derive(Debug)]
enum BackendExit {
/// The vanilla client went away before/after the greeting.
ClientGone,
/// The RFC conversation ended (its `SocksError` — the mapped
/// The RFC conversation ended (its `SocksError`: the mapped
/// refusal, the protocol garbage, or the session end).
Session(crate::SocksError),
/// A plain I/O failure on the accepted socket.
@@ -75,7 +75,7 @@ impl std::fmt::Display for BackendExit {
}
/// The producer-side local backend (feature `local`): a real
/// `TcpListener` front door for [`Socks5Producer`] — each accepted
/// `TcpListener` front door for [`Socks5Producer`]; each accepted
/// TCP connection is served via `serve_socks5_over` (the NoAuth
/// in-stream RFC conversation; the seams dial locally). The accept
/// path owns the two ADR-014 defense knobs (the idle-first-byte
@@ -95,7 +95,7 @@ impl ProducerDoor {
}
/// The builder for [`ProducerDoor`]. The producer and the bind
/// address are required (the explicit bind decision — never implied);
/// address are required (the explicit bind decision, never implied);
/// the two defense knobs carry the consumer door's defaults.
#[derive(Default)]
pub struct ProducerDoorBuilder {
@@ -107,16 +107,16 @@ pub struct ProducerDoorBuilder {
impl ProducerDoorBuilder {
/// The producer whose `serve_socks5_over` serves every accepted
/// connection (the landed assembly shape — its seams dial/egress/
/// connection (the landed assembly shape, with its seams dial/egress/
/// accept; its `bind_policy` is this path's BIND gate).
pub fn producer(mut self, producer: Socks5Producer) -> Self {
self.producer = Some(producer);
self
}
/// The local bind address — the backend's explicit bind decision
/// The local bind address: the backend's explicit bind decision
/// (the never-binds posture: the protocol layer never binds; this
/// is the assembly layer's spelled-out choice — loopback is the
/// is the assembly layer's spelled-out choice, with loopback the
/// documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into());
@@ -126,7 +126,7 @@ impl ProducerDoorBuilder {
/// The idle-first-byte window (ADR-014 knob 1): an accepted
/// connection that sends no first byte within this window is
/// closed door-side. The window covers the first byte of the RFC
/// conversation — the greeting (`05 ..`), read producer-side by
/// conversation, the greeting (`05 ..`), read producer-side by
/// `serve_socks5_over`'s own handshake (the peek is non-consuming).
pub fn idle_first_byte(mut self, window: Duration) -> Self {
self.idle_first_byte = Some(window);
@@ -134,10 +134,10 @@ impl ProducerDoorBuilder {
}
/// The max-concurrent-*connections* cap (ADR-014 knob 2; review
/// 003 N-3's naming — the mechanism bounds accepted connections,
/// 003 N-3's naming: the mechanism bounds accepted connections,
/// including ones past the idle gate that are merely slow
/// readers, not active RFC sessions): an accept beyond the bound
/// is refused door-side — the socket is closed immediately, no
/// is refused door-side; the socket is closed immediately, no
/// session spawned.
pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap);
@@ -158,20 +158,20 @@ impl ProducerDoorBuilder {
}
/// Why the producer-door builder refused to construct (the builder's
/// typed construction error — a missing required field; convention 2:
/// typed construction error, a missing required field; convention 2:
/// no panic path).
#[derive(Debug, thiserror::Error)]
pub enum ProducerDoorBuildError {
#[error("the backend requires a producer (.producer) — there is nothing to serve")]
#[error("the backend requires a producer (.producer); there is nothing to serve")]
MissingProducer,
}
/// Why the producer door's `serve` refused to start (the typed
/// construction error — `serve` carries the bind decision, so an
/// construction error: `serve` carries the bind decision, so an
/// omitted `.bind` is its error, not a panic; convention 2).
#[derive(Debug, thiserror::Error)]
pub enum ProducerDoorError {
#[error("the backend requires a bind address (.bind) before serve — the bind decision is never implied")]
#[error("the backend requires a bind address (.bind) before serve; the bind decision is never implied")]
MissingBindAddr,
#[error("tcp bind: {0}")]
Bind(#[source] std::io::Error),
@@ -179,7 +179,7 @@ pub enum ProducerDoorError {
/// The serving guard: [`ProducerDoor::serve`] returns one; `close()`
/// ends the accept loop gracefully (the listener is dropped, the
/// loop's end is awaited — the spawned sessions already running own
/// loop's end is awaited; the spawned sessions already running own
/// their sockets and finish on their own), and dropping it aborts the
/// loop task best-effort (the alktunnels `TunnelListener` teardown
/// posture).
@@ -190,12 +190,12 @@ pub struct ProducerDoorGuard {
impl ProducerDoorGuard {
/// End the accept loop gracefully: the loop task returns, and the
/// guard awaits its end. In-flight sessions are NOT force-closed —
/// guard awaits its end. In-flight sessions are NOT force-closed;
/// they own their sockets and finish with their clients (per-
/// connection isolation cuts both ways). Returns the loop task's
/// fatal outcome, if any (`None` = the loop ended by close).
///
/// The receiver is dropped before the await — the loop's
/// The receiver is dropped before the await: the loop's
/// `done_tx.closed()` fires only when every receiver is gone;
/// holding it across the await would deadlock the graceful stop.
pub async fn close(mut self) -> Option<std::io::Error> {
@@ -211,7 +211,7 @@ impl ProducerDoorGuard {
impl Drop for ProducerDoorGuard {
/// Best-effort abort: drop the loop task's handle (the loop ends
/// when its listener drops — the listener is owned by the task;
/// when its listener drops, since the listener is owned by the task;
/// an abort cancels the accept wait). Sync-only, never awaits.
fn drop(&mut self) {
if let Some(task) = self.task.take() {
@@ -222,7 +222,7 @@ impl Drop for ProducerDoorGuard {
impl ProducerDoor {
/// Bind the configured address and run the accept loop. Per-
/// connection failures are logged + isolated — the loop continues
/// connection failures are logged + isolated; the loop continues
/// (the alktunnels per-accept isolation posture). The listener's
/// lifetime is the caller's: the returned [`ProducerDoorGuard`]
/// ends it (`close()` graceful; drop best-effort abort).
@@ -246,7 +246,7 @@ impl ProducerDoor {
}
/// Run the accept loop over an existing listener (the test/
/// assembly seam — the same accept loop without the bind).
/// assembly seam: the same accept loop without the bind).
pub async fn serve_on(self, listener: TcpListener) -> ProducerDoorGuard {
let ProducerDoor {
producer,
@@ -280,11 +280,11 @@ fn serve_on_parts(
};
// The session cap (ADR-014 knob 2): refuse
// beyond the bound — `try_acquire` keeps the
// beyond the bound; `try_acquire` keeps the
// accept loop free (a permit returns when a
// serving task ends and drops its guard).
let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "producer door: session cap reached — refusing");
tracing::warn!(peer = %peer, "producer door: session cap reached, refusing");
drop(stream);
continue;
};
@@ -306,9 +306,9 @@ fn serve_on_parts(
}
/// Serve one accepted connection: the idle-first-byte defense first
/// (ADR-014 knob 1 — the peek waits for readability without
/// (ADR-014 knob 1: the peek waits for readability without
/// consuming; the producer's own greeting read picks the byte up),
/// then the substrate-free entry (`serve_socks5_over` — the NoAuth
/// then the substrate-free entry (`serve_socks5_over`, the NoAuth
/// in-stream RFC conversation; the seams dial locally).
async fn serve_accepted(
stream: tokio::net::TcpStream,
+24 -24
View File
@@ -1,6 +1,6 @@
//! The local providers' unit tests: real sockets — the echo TCP
//! server for [`super::tcp_dial`] round-trips, a real UDP echo for
//! the egress ([`super::LocalEgress`], the POC #2 shape — including
//! The local providers' unit tests: real sockets, including the echo
//! TCP server for [`super::tcp_dial`] round-trips, a real UDP echo for
//! the egress ([`super::LocalEgress`], the POC #2 shape, including
//! the readiness contract's park-then-datagram shape), a real BIND
//! accept ([`super::bind_accept`], reply#1/reply#2 carrying the real
//! addresses), and the refusal mappings table-tested against the
@@ -19,7 +19,7 @@ use crate::rfc1928::consts;
const BOUND: Duration = Duration::from_secs(10);
/// Decode the inner bytes' `(addr, payload)` shape — the forked
/// Decode the inner bytes' `(addr, payload)` shape: the forked
/// `read_address` codec applied to the egress's reply (an honest
/// decode through the codec, not a hand-parse: the inner bytes are
/// `ATYP + addr + port + data`; the codec reads exactly the address
@@ -68,7 +68,7 @@ async fn echo_tcp_server() -> SocketAddr {
}
/// A UDP echo server: datagrams echo back from their sender address
/// (the egress test's far side — the POC #2 shape).
/// (the egress test's far side; the POC #2 shape).
async fn echo_udp_server() -> SocketAddr {
let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind");
let addr = sock.local_addr().expect("local_addr");
@@ -90,7 +90,7 @@ async fn echo_udp_server() -> SocketAddr {
/// The happy path: dial the echo server, round-trip bytes through the
/// boxed halves (ownership rides the handle; the halves are
/// Send+Sync+Unpin — the boxing compiles, pinned here).
/// Send+Sync+Unpin; the boxing compiles, pinned here).
#[tokio::test]
async fn tcp_dial_round_trips_through_the_boxed_halves() {
let server = echo_tcp_server().await;
@@ -110,7 +110,7 @@ async fn tcp_dial_round_trips_through_the_boxed_halves() {
}
/// The `to_target_addr`-shaped target (the `impl ToTargetAddr`
/// consumer path): a domain that resolves here (`localhost` — the
/// consumer path): a domain that resolves here (`localhost`; the
/// `-D` semantic: resolution is the provider's job) reaches the same
/// echo server; the seam received it unresolved and dialed the
/// resolved address.
@@ -133,7 +133,7 @@ async fn tcp_dial_resolves_domain_targets_here() {
}
/// An unroutable loopback port refuses: `ConnectionRefused` (0x05)
/// per the io error kind — the refusal rides the seam's error
/// per the io error kind; the refusal rides the seam's error
/// vocabulary (`to_reply_error()`), table-pinned through the mapped
/// code.
#[tokio::test]
@@ -157,7 +157,7 @@ async fn tcp_dial_connect_refused_maps_faithfully() {
);
}
/// A domain that does not resolve → `HostUnreachable` (0x04) — the
/// A domain that does not resolve → `HostUnreachable` (0x04): the
/// resolution failure is the dial seam's error, faithfully mapped.
#[tokio::test]
async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
@@ -178,8 +178,8 @@ async fn tcp_dial_unresolvable_domain_maps_to_host_unreachable() {
/// The datagram round-trip through a real UDP echo (the POC #2
/// shape): `send` (payload-only) → the echo → `next_reply`'s inner
/// bytes carry the sender's address + the data (no length prefix —
/// framing-once; the wire layer adds it downstream).
/// bytes carry the sender's address + the data (no length prefix, as
/// framing happens once; the wire layer adds it downstream).
#[tokio::test]
async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
let echo = echo_udp_server().await;
@@ -202,14 +202,14 @@ async fn udp_egress_round_trips_inner_bytes_with_sender_address() {
assert_eq!(frame, Bytes::from(expect), "inner bytes, framed-once");
// The sender's address decodes as the echo's (the RFC's
// per-datagram source semantics — the address rides the frame).
// per-datagram source semantics; the address rides the frame).
let (src, payload) = decode_inner(&frame).await;
assert_eq!(src, dest);
assert_eq!(&payload[..], b"query");
}
/// The readiness contract with the real socket: `next_reply` parks
/// (no reply is ready → `Pending`) — the stage's select! branch would
/// (no reply is ready → `Pending`); the stage's select! branch would
/// stay parked; a datagram arriving resolves it. Pinned by racing the
/// wait against a `sleep`: no reply → not resolved by the bound; the
/// datagram → resolved. Never a hot `Ok(None)` (the send-then-drain
@@ -238,7 +238,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"park");
// The socket drained: a second wait parks again (no reply is
// ready) — resolved only by a fresh datagram.
// ready), resolved only by a fresh datagram.
egress
.send(TargetAddr::Ip(echo), Bytes::from_static(b"again"))
.await
@@ -261,7 +261,7 @@ async fn udp_egress_next_reply_parks_until_a_datagram_arrives() {
assert_eq!(&payload[..], b"again");
}
/// Multiple datagrams in order (no flow table — per-datagram
/// Multiple datagrams in order (no flow table; per-datagram
/// addressing does the demultiplexing; the socket is the relay
/// handle).
#[tokio::test]
@@ -287,7 +287,7 @@ async fn udp_egress_round_trips_multiple_datagrams_in_order() {
}
/// A zero-length payload is a legal empty datagram (the F-2
/// invariant at payload level) — round-trips through the real
/// invariant at payload level) and round-trips through the real
/// socket.
#[tokio::test]
async fn udp_egress_zero_length_payload_round_trips() {
@@ -307,7 +307,7 @@ async fn udp_egress_zero_length_payload_round_trips() {
}
/// An undecodable destination (an unresolvable domain) fails the
/// `send` with `Unreachable` → `NetworkUnreachable` (0x03) — the
/// `send` with `Unreachable` → `NetworkUnreachable` (0x03); the
/// resolve failure is the egress's error vocabulary.
#[tokio::test]
async fn udp_egress_unresolvable_destination_maps_to_unreachable() {
@@ -366,7 +366,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
);
// The client echoes what it receives (the round-trip's far side:
// the app server the RFC models — active-mode FTP's data
// the app server the RFC models, i.e. active-mode FTP's data
// listener echo).
let mut client_to_echo = client;
tokio::spawn(async move {
@@ -409,7 +409,7 @@ async fn bind_accept_reply1_real_addr_reply2_real_peer_and_round_trip() {
/// The requested port is honored when non-zero (FTP active mode):
/// the listener binds exactly the requested port, and a held port
/// refuses the factory (`Shortage`/`Refused` per the io error — the
/// refuses the factory (`Shortage`/`Refused` per the io error; the
/// bind failure is reply#1's vocabulary).
#[tokio::test]
async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
@@ -433,7 +433,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
);
// A free port is honored exactly (the listener is the factory's
// resource — dropped with the accept future).
// resource, dropped with the accept future).
let free = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let want = free.local_addr().expect("local_addr");
drop(free);
@@ -445,7 +445,7 @@ async fn bind_accept_honors_a_requested_port_and_refuses_a_held_one() {
}
/// The accept future owns its listener end-to-end: dropping the
/// `BindAccept` (the quiet wait's drop — the client-gone teardown)
/// `BindAccept` (the quiet wait's drop, i.e. the client-gone teardown)
/// closes the listener; the advertised address stops accepting. A
/// fresh factory on the same (now free) port re-binds.
#[tokio::test]
@@ -458,7 +458,7 @@ async fn bind_accept_drop_of_the_accept_future_closes_the_listener() {
// The listener closed with the future: a fresh bind of the same
// ephemeral port succeeds (the OS may hand the port to either
// the reuse or a fresh allocation — loopback ephemeral binds
// the reuse or a fresh allocation; loopback ephemeral binds
// succeed either way; the probe is that the re-bind is possible
// at all, i.e. the listener was dropped, not leaked).
let rebinder = TcpListener::bind(bind_addr).await;
@@ -476,7 +476,7 @@ type DialCase = (ErrorKind, &'static dyn Fn(&SocksDialError) -> bool);
type BindCase = (ErrorKind, &'static dyn Fn(&BindAcceptFactoryError) -> bool);
/// The dial map is table-tested (`dial_error_of` is a pure function
/// of `io::ErrorKind` — review 003 D-3): a refactor that swaps an arm
/// of `io::ErrorKind`; review 003 D-3): a refactor that swaps an arm
/// into the `_` catch-all fails here. The two loopback-unproducible
/// kinds (`NetworkUnreachable`, `HostUnreachable`, `TimedOut`,
/// `PermissionDenied`) ride synthetic errors of the right kind.
@@ -528,7 +528,7 @@ fn dial_error_of_maps_the_vocabulary_table() {
}
/// The dial vocabulary's reply codes stay RFC-faithful (the mapped
/// surface the producer replies with — the split's whole purpose).
/// surface the producer replies with; that is the split's whole purpose).
#[test]
fn dial_vocabulary_reply_codes_table() {
use crate::egress::SocksDialError;
+24 -24
View File
@@ -1,20 +1,20 @@
//! The trivial open-op params, the wire-stable constants, and the
//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006.
//! `OperationSpec` builder (`wire.md` §The Open Op; ADR-001, ADR-006).
//!
//! This module is the crate's wire surface (the one-way door): the op
//! id, the ALPN string, the two scope strings, and the trivial params
//! shape are frozen at first publish (ADR-001). The open-op params are
//! **trivially empty** — the SOCKS5 target is unknown at open time (it
//! **trivially empty**: the SOCKS5 target is unknown at open time (it
//! arrives in-stream post-negotiation); the establisher is a session
//! gate, not a dial. The empty-object schema with
//! `additionalProperties: false` keeps `{}` valid forever: any future
//! per-session option is an *additive* schema extension (ADR-001's
//! extension rule) — consumers that send `{}` stay valid.
//! extension rule), so consumers that send `{}` stay valid.
//!
//! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the
//! one open op, selected in-stream at command dispatch (ADR-001); BIND
//! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`,
//! ADR-006) — the command is an RFC-1928 wire fact, not a wire-format
//! ADR-006): the command is an RFC-1928 wire fact, not a wire-format
//! choice of ours.
//!
//! The typed open-error vocabulary ([`crate::error::SocksOpenError`],
@@ -29,7 +29,7 @@ use alkcall::registry::spec::{
use serde::{Deserialize, Serialize};
use serde_json::json;
/// The open-op operation id (the one SOCKS5 session op; ADR-001 —
/// The open-op operation id (the one SOCKS5 session op; ADR-001,
/// flavor-form per alkcall ADR-047: openable ALPNs are operations).
/// All three RFC 1928 commands ride this one op. Wire-stable.
pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
@@ -39,13 +39,13 @@ pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub";
/// per protocol. Wire-stable.
pub const SOCKS5_ALPN: &str = "alk/socks5";
/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT
/// and UDP ASSOCIATE. The scope-string convention
/// The scope gating SOCKS5 opens (ADR-006): proxy egress (CONNECT
/// and UDP ASSOCIATE). The scope-string convention
/// `<alpn-family>:<capability>`. Stable once published.
pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// The scope gating TCP BIND at command dispatch (ADR-006): the
/// *inbound* capability — ingress listeners. A separate grant from
/// *inbound* capability: ingress listeners. A separate grant from
/// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with
/// proxy egress must not implicitly get ingress listeners. Checked
/// in-stream at command read, from the per-call opener identity
@@ -54,21 +54,21 @@ pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open";
/// it. Stable once published.
pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind";
/// The open-op input params (ADR-001): the trivial object. Wire-stable
/// — the shape is frozen (empty object, nothing required, nothing
/// The open-op input params (ADR-001): the trivial object. Wire-stable,
/// since the shape is frozen (empty object, nothing
/// else); a future per-session option is an additive schema extension,
/// never a format change.
///
/// `deny_unknown_fields` is the loud-rejection posture: unknown fields
/// fail at parse time, never silently ignored (matching the input
/// schema's `additionalProperties: false` — the registry's gate and
/// schema's `additionalProperties: false`; the registry's gate and
/// this serde pin agree). Empty params stay valid forever.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Socks5Params {}
impl Socks5Params {
/// The canonical wire form: the empty object — the only valid
/// The canonical wire form: the empty object, the only valid
/// params value (ADR-001).
pub fn as_json() -> serde_json::Value {
json!({})
@@ -79,22 +79,22 @@ impl Socks5Params {
/// `Sub`-typed, external, channel-open marker on the `alk/socks5`
/// ALPN, scope-gated by `socks5:open`, trivial params.
///
/// - Input schema: `{type: object}` + `additionalProperties: false` —
/// - Input schema: `{type: object}` + `additionalProperties: false`;
/// empty params stay valid forever (ADR-001's additive-only
/// extension rule); unknown fields are rejected loudly.
/// - Reply schema: `{"channel_id": <u32>, minimum 1}` — the
/// - Reply schema: `{"channel_id": <u32>, minimum 1}`, the
/// `Sub`-typed operation reply. No extra reply fields in v1 (the
/// establishment phase is a session gate and has nothing to
/// project; alkcall's `with_reply_field` seam remains available
/// additively if a future need appears — ADR-001).
/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL
/// additively if a future need appears (ADR-001).
/// - `required_scopes = ["socks5:open"]`: the registry runs the ACL
/// before the open handler (ADR-006; the open gate is the
/// boundary).
///
/// `error_schemas` declare the `channel:open_failed` contract
/// (alkcall ADR-049 §3/§4 + ADR-016): the establisher's refusal
/// reasons (`dial_failed` / `unknown_resource` / `resource_shortage`
/// / `handler_error` / `timeout` — the ADR-049 §3 vocabulary, the
/// / `handler_error` / `timeout`: the ADR-049 §3 vocabulary, the
/// same five codes [`crate::error::SocksEstablishmentReason`] parses)
/// plus the registry's own `FORBIDDEN` class (the ACL gate runs
/// before the establisher; ADR-006). The wrapper's wire shape is
@@ -139,11 +139,11 @@ pub fn socks5_open_spec() -> OperationSpec {
vec![ErrorDefinition {
code: "channel:open_failed".to_string(),
description: "The channel open failed: the registry's ACL gate refused \
(FORBIDDEN — no socks5:open scope), the params payload did \
(FORBIDDEN: no socks5:open scope), the params payload did \
not match the schema (INVALID_INPUT), the per-connection \
channel cap was hit (channel:too_many_channels), or the \
establishment phase failed (details.reason ∈ the ADR-049 \
§3 vocabulary). No channel_id is returned — no phantom \
§3 vocabulary). No channel_id is returned, so no phantom \
channel (ADR-049)."
.to_string(),
schema: open_failed_schema,
@@ -160,7 +160,7 @@ pub fn socks5_open_spec() -> OperationSpec {
.with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN))
.with_description(
"Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \
trivial params — the target arrives in-stream post-negotiation)",
trivial params; the target arrives in-stream post-negotiation)",
)
}
@@ -169,7 +169,7 @@ pub fn socks5_open_spec() -> OperationSpec {
/// vocabulary. `None` for any other failure shape (the registry's
/// `FORBIDDEN`, `channel:too_many_channels`, local-only failures).
///
/// Delegates to [`ChannelOpenError::establishment_reason`] — the
/// Delegates to [`ChannelOpenError::establishment_reason`] (the
/// alktunnels helper posture. The typed vocabulary lives in
/// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the
/// parsed view a consumer branches on via
@@ -191,7 +191,7 @@ mod tests {
assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External);
// Trivial params: empty object, unknown fields rejected loudly
// (ADR-001 — empty params stay valid forever).
// (ADR-001: empty params stay valid forever).
assert_eq!(spec.input_schema["type"], "object");
assert_eq!(spec.input_schema["additionalProperties"], json!(false));
assert!(spec.input_schema.get("required").is_none());
@@ -229,7 +229,7 @@ mod tests {
#[test]
fn trivial_params_reject_unknown_fields() {
// The loud-rejection posture — the serde pin agrees with the
// The loud-rejection posture: the serde pin agrees with the
// input schema's additionalProperties: false (ADR-001).
let v = json!({"resource": "sneaky"});
assert!(serde_json::from_value::<Socks5Params>(v).is_err());
@@ -270,7 +270,7 @@ mod tests {
let enum_values = props["reason"]["enum"].as_array().expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The declared vocabulary is exactly the typed parse table
// (crate::error::SocksEstablishmentReason) — the schema and
// (crate::error::SocksEstablishmentReason); the schema and
// the typed view cannot drift.
assert_eq!(
reasons,
+173 -173
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,7 +9,7 @@ fast-socks5 v1.0.0 (https://github.com/dizda/fast-socks5) and are
carried in-tree by alksocks (extraction per ADR-011,
docs/architecture/decisions/011-inline-vendor.md; extraction range
git 57b107b..5b18d4d; fork point = the tree as of commit 5b18d4d,
the extraction review's gate commit — ownership per ADR-013,
the extraction review's gate commit; ownership per ADR-013,
docs/architecture/decisions/013-targeted-fork.md). Upstream has no
per-file license headers; this bundled notice is the license
obligation's carrier. The extraction cut and the fork's deliberate
@@ -19,7 +19,7 @@ Modifications after the fork point are Copyright (c) 2026 the
alksocks developers, licensed under the crate license
(MIT OR Apache-2.0, per alksocks' Cargo.toml); the upstream MIT
notice above is preserved per its terms. The upstream project is
third-party and unmodified in place — see the ADRs for what was
third-party and unmodified in place; see the ADRs for what was
changed after the fork point.
---
+10 -10
View File
@@ -1,12 +1,12 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/client.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Extracted region: the `T`-generic
//! client session — [`Socks5Stream<S>`] with the `use_stream` path
//! client session: [`Socks5Stream<S>`] with the `use_stream` path
//! (the version/method greeting, the method-choice read, the
//! password-auth sub-handshake, the request encoder `request_header`,
//! the reply reader `read_request_reply`, and the socket accessors)
//! plus the [`AsyncRead`]/[`AsyncWrite`] impls — the consumer half's
//! plus the [`AsyncRead`]/[`AsyncWrite`] impls, the consumer half's
//! stream session (ADR-002; POC #1's fit: a channels `BiStream` is
//! fed here directly). A `Socks5Stream` is itself a stream, so
//! `pump_bidi`-shaped consumers and the front doors can pipe through
@@ -14,10 +14,10 @@
//!
//! Not forked (rfc1928.md's not-forked inventory row 2):
//! `Socks5Datagram`
//! (struct + impl — the kernel `UdpSocket` associate client; the
//! (struct + impl: the kernel `UdpSocket` associate client; the
//! crate's associate session is first-party per ADR-003's handle
//! swap) and the `Socks5Stream<TcpStream>` convenience impl
//! (`connect`/`connect_with_password`/`connect_raw` — the
//! (`connect`/`connect_with_password`/`connect_raw`, the
//! `tokio::net::TcpStream` dialers; the consumer's transport is a
//! channels `BiStream`, not a TCP dial). Upstream
//! `Config::connect_timeout` is cut with its only consumer,
@@ -26,13 +26,13 @@
//!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
//! `log` calls dropped (the bindings they consumed renamed); `anyhow`
//! dropped — errors stay `SocksError`-typed (the missing-target-addr
//! dropped; errors stay `SocksError`-typed (the missing-target-addr
//! case at upstream `client.rs:285` uses the typed
//! [`SocksError::MissingTargetAddr`] variant instead of
//! `anyhow::Error::msg(...).into()`); `use_password_auth`'s
//! `unreachable!()` (upstream `client.rs:207` — `methods.get(1)`
//! `unreachable!()` (upstream `client.rs:207`, where `methods.get(1)`
//! being `Some(AuthenticationMethod::None)`) is an error return
//! ([`SocksError::AuthenticationRejected`]) — convention 2 forbids
//! ([`SocksError::AuthenticationRejected`]); convention 2 forbids
//! panics in library code, and the state is reachable only by misuse;
//! the `Socks5Datagram` error sites (`SocksError::Other(anyhow!(...))`,
//! the `"[::]:0".parse().unwrap()` sites) go with the cut region. No
@@ -41,7 +41,7 @@
//!
//! Tests are first-party (upstream's client test module rides the cut
//! net region): raw-byte mock servers over tokio duplex streams, the
//! pinned byte sequences written directly — keeping the client's wire
//! pinned byte sequences written directly, keeping the client's wire
//! expectations uncorrelated from the forked server typestate.
use crate::rfc1928::util::stream::read_exact;
@@ -208,7 +208,7 @@ where
ref username,
ref password,
}) => (username, password),
// upstream `unreachable!()` — `methods.get(1)` being
// upstream `unreachable!()` (`methods.get(1)` being
// `Some(AuthenticationMethod::None)`: reachable only by
// misuse; the faithful translation is an error return,
// not a panic (convention 2). Same message as upstream's
+7 -7
View File
@@ -1,12 +1,12 @@
//! The RFC 1928 core: a targeted fork of fast-socks5 v1.0.0's
//! `T`-generic subset (MIT, third-party upstream —
//! `T`-generic subset (MIT, third-party upstream:
//! github.com/dizda/fast-socks5; `/workspace/fast-socks5` is the
//! read-only reference checkout, kept for differential testing of
//! RFC edge cases).
//!
//! Arrived by extraction (ADR-011 §Amendment, docs/architecture/
//! decisions/011-inline-vendor.md): only the code already generic
//! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree — the
//! over `T: AsyncRead + AsyncWrite + Unpin` was copied in-tree; the
//! kernel-net handlers, the `socks4` module, and the legacy `Config`/
//! `Authentication` layer were **not** copied. The extraction cut,
//! the fork's two carried changes (BIND support, `new_reply`
@@ -15,7 +15,7 @@
//! `docs/architecture/rfc1928.md`.
//!
//! Ownership (ADR-013, docs/architecture/decisions/013-targeted-fork.md):
//! this is fully owned code — project conventions apply
//! this is fully owned code, so project conventions apply
//! unconditionally (no vendor carve-outs, no verbatim preservation,
//! no lint-allow posture). Fork point: the tree as of the
//! extraction-review commit (`5b18d4d`); extraction provenance in
@@ -32,13 +32,13 @@
//! `use_stream` path + the `AsyncRead`/`AsyncWrite` impls) lives in
//! [`client`].
//!
//! License: upstream is MIT with no per-file headers — the bundled
//! License: upstream is MIT with no per-file headers; the bundled
//! notice (`LICENSE`, this module's directory, v1.0.0 attribution +
//! the fork-point statement) is the license obligation's carrier.
//!
//! Maintenance posture: this code co-evolves with the wrapper and is
//! the crate's only RFC 1928 wire vocabulary ("no second codec,
//! ever" — ADR-011's rationale, carried). Nothing is absorbed
//! ever": ADR-011's rationale, carried). Nothing is absorbed
//! silently from upstream; nothing waits on upstream.
pub mod client;
@@ -216,7 +216,7 @@ impl ReplyError {
/// Map a wire reply code to its variant. Codes outside the RFC
/// table (`0x09`–`0xFF`, unassigned per RFC 1928 §6) map to
/// `GeneralFailure` — the RFC's catch-all — instead of panicking:
/// `GeneralFailure`, the RFC's catch-all, instead of panicking:
/// the value comes off the wire (the client session feeds the raw
/// reply byte here), so a misbehaving peer must surface as a typed
/// error, never a panic. Upstream's `unreachable!()` arm was the
@@ -379,7 +379,7 @@ mod tests {
}
/// F-1 (review): unassigned reply codes (`0x09`–`0xFF`, RFC 1928
/// §6) surface as `GeneralFailure` — the RFC's catch-all — never
/// §6) surface as `GeneralFailure` (the RFC's catch-all), never
/// a panic. Upstream's `unreachable!()` arm was removed: the code
/// comes off the wire (`read_request_reply` → `from_u8`), so a
/// misbehaving peer must not crash the consumer task.
+16 -16
View File
@@ -1,4 +1,4 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/server.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Extracted region: the explicit
@@ -6,13 +6,13 @@
//! negotiation with the `AuthMethod<T>` machinery, `read_command`,
//! `reply_success`, `reply_error`, `transfer`, `wait_on_tcp`,
//! `ErrorContext<T>`), `SocksServerError`, and the published reply
//! encoder `new_reply` (carried change #2 — upstream's is private,
//! encoder `new_reply` (carried change #2: upstream's is private,
//! `fn new_reply` at `server.rs:1380`; here it is `pub` so the
//! BIND reply#2 writer and consumer reply readers reuse the
//! 10/22-byte reply form instead of re-encoding it). The cut regions
//! (the legacy `Socks5Server`/`Socks5Socket`/`Config`/`Authentication`
//! layer, the `run_*` kernel-net handlers, `DnsResolveHelper`, the
//! net-only tests) stay in `/workspace/fast-socks5` — the not-forked
//! net-only tests) stay in `/workspace/fast-socks5`, the not-forked
//! inventory row 1 in `docs/architecture/rfc1928.md`.
//!
//! Extraction-time adjustments (mechanical, ADR-011 §Amendment):
@@ -23,22 +23,22 @@
//! `SocksServerError::AuthenticationRejected` are cut; the variant
//! stays (the typestate's `accept_password_auth` produces it).
//!
//! Post-fork owned changes (extend, never replace — convention 17):
//! Post-fork owned changes (extend, never replace, per convention 17):
//! the keep-or-cut sweep (review 002 N-1) cut the producer-replaced
//! machinery — the `auth_method_enums!` macro (upstream's enum
//! machinery: the `auth_method_enums!` macro (upstream's enum
//! generator) with its generated `StandardAuthentication` enum +
//! `allow_no_auth`, `skip_auth_this_is_not_rfc_compliant`, and
//! `CheckResult`'s `Option`/`Result` impls (`bool` stays) — none had
//! `CheckResult`'s `Option`/`Result` impls (`bool` stays); none had
//! an in-tree consumer once the producer drove `negotiate_auth`
//! directly. Kept with note (the hand-port map, ADR-013):
//! `transfer`/`wait_on_tcp` are differential-suite-consumed (their
//! semantics stay pinned there), and the error vocabulary keeps its
//! upstream variants. One variant was added, not absorbed:
//! `SocksServerError::Refused` — the honest refusal vocabulary for
//! `SocksServerError::Refused`: the honest refusal vocabulary for
//! the producer's in-stream policy refusals (review 002 N-4; never
//! reaches the wire). Recorded in docs/architecture/rfc1928.md.
//!
//! The BIND note (ADR-007): BIND's driving is producer-side code —
//! The BIND note (ADR-007): BIND's driving is producer-side code;
//! the typestate already returns `TCPBind` from `read_command()`,
//! `reply_success(sock_addr)` carries reply#1's BND.ADDR, and
//! reply#2 is written with the published `new_reply`. No BIND code
@@ -159,7 +159,7 @@ impl<T> Socks5ServerProtocol<T, states::Opened> {
/// Closure-result adapter for [`Socks5ServerProtocol
/// ::accept_password_auth`]'s credential checker: `bool` (`is_good`).
/// The `Option`/`Result` impls upstream-adjacent shape were cut in
/// the keep-or-cut sweep (review 002 N-1) — every in-tree checker
/// the keep-or-cut sweep (review 002 N-1); every in-tree checker
/// returns `bool`; re-add with a typed-rejection design if a door
/// ever wants it.
pub trait CheckResult {
@@ -240,7 +240,7 @@ pub trait AuthMethodSuccessState<T> {
/// Use blank structs for individual protocol implementations;
/// `negotiate_auth` takes any slice of `AuthMethod`s, so a custom
/// selection is a slice of the method structs (upstream's
/// enum-generator macro was cut — review 002 N-1; nothing in-tree
/// enum-generator macro was cut (review 002 N-1); nothing in-tree
/// drove a multi-method slice).
pub trait AuthMethod<T>: Copy {
type StartingState;
@@ -354,7 +354,7 @@ impl<T: AsyncWrite + Unpin> PasswordAuthenticationImpl<T, password_states::Recei
/// Notify the client with a "NOT_ACCEPTABLE" reply and drop the socket.
///
/// The status byte is `0xff` — RFC 1929 requires only that a
/// The status byte is `0xff`: RFC 1929 requires only that a
/// rejection be any non-`0x00` byte; `0xff` is the convention
/// upstream picked (borrowed from the method-negotiation phase's
/// `SOCKS5_AUTH_METHOD_NOT_ACCEPTABLE` constant, which is a
@@ -530,7 +530,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin> Socks5ServerProtocol<T, states::Authenti
}
/// The RFC reply form's placeholder BND.ADDR for error replies
/// (statically constructed — upstream parsed `"0.0.0.0:0"` at runtime).
/// (statically constructed; upstream parsed `"0.0.0.0:0"` at runtime).
const V4_UNSPECIFIED_ZERO: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0));
/// Generate reply code according to the RFC.
@@ -606,14 +606,14 @@ mod tests {
/// `SocksServerError::to_reply_error`'s full mapping table (review
/// 001 L-3): `UnknownCommand` → `CommandNotSupported`,
/// `AddrError` delegates to its own table, and every remaining
/// variant falls through to `GeneralFailure` — the mapping the
/// variant falls through to `GeneralFailure`, the mapping the
/// producer's error surface relies on when mapping session errors
/// to reply codes (`try_notify!`'s secondary-error write). The
/// fall-through arms (`Io`/`FromUtf8`/`EOF`/auth/refusal variants)
/// are exercised here directly, since driving each from the wire
/// is not one-test-cheap. Upstream `src/server.rs:62-70`. The
/// table also pins the vocabulary honesty (review 002 N-4):
/// `Bug` and `Refused` are log-vocabulary variants — both fall
/// `Bug` and `Refused` are log-vocabulary variants; both fall
/// through to `GeneralFailure` (they never reach the wire from
/// the typestate; the producer's refusal sites write the RFC
/// reply before the session error).
@@ -655,13 +655,13 @@ mod tests {
}
/// The refusal vocabulary (review 002 N-4): `Refused` is the
/// honest display for the producer's in-stream policy refusals —
/// honest display for the producer's in-stream policy refusals:
/// not `BUG:` (a working-as-designed refusal is not a bug) and
/// not "Authentication rejected" (an ACL refusal is not an auth
/// failure). It is log/observability vocabulary: never encoded
/// on the wire by the typestate (only `to_reply_error` maps it,
/// and the producer's refusal sites write the RFC reply before
/// the session error). Upstream has no such variant — the
/// the session error). Upstream has no such variant; that is the
/// fork's owned addition, recorded in the module doc.
#[test]
fn refused_variant_display_is_honest_and_wire_invisible() {
+2 -2
View File
@@ -1,10 +1,10 @@
//! The RFC 1928 core's utility modules (upstream `src/util/` —
//! The RFC 1928 core's utility modules (upstream `src/util/`;
//! extraction; see the fork statement in the crate root's `mod.rs`
//! and ADR-013 for ownership).
//!
//! Upstream layout: `stream.rs` (the `read_exact!`/`ready!` macros +
//! `ConnectError`) and `target_addr.rs` (`TargetAddr` + the address
//! codec) — filled by the wire-vocabulary extraction task.
//! codec), filled by the wire-vocabulary extraction task.
pub mod stream;
pub mod target_addr;
+4 -4
View File
@@ -1,10 +1,10 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs` —
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/stream.rs`;
//! extraction; ownership per ADR-013, see the crate root's `mod.rs`).
//!
//! Upstream carries the `read_exact!`/`ready!` macros, `ConnectError`,
//! and the `tcp_connect*` dialers; only the `read_exact!` macro +
//! `ConnectError` rode the extraction (the dialers and the `ready!`
//! macro are the cut kernel-net region — the crate's local backend is
//! macro are the cut kernel-net region: the crate's local backend is
//! first-party tokio code, ADR-008, and nothing in the extracted
//! region used `ready!`). Extraction-time adjustment: upstream
//! exports the macro at the crate root via `#[macro_export]`; here it
@@ -13,11 +13,11 @@
//! public API).
//!
//! [`ConnectError`] is currently constructed nowhere in-tree (review
//! 001 C-1 — upstream's sole construction site was the cut
//! 001 C-1: upstream's sole construction site was the cut
//! `tcp_connect` dialer). The keep-or-cut trigger (cut if the
//! producer layer landed without consuming it) fired and the
//! disposition is **keep** (review 002 N-1): it stays as the
//! hand-port map's companion — the dial-error → reply-code mapping
//! hand-port map's companion (the dial-error → reply-code mapping
//! (`to_reply_error`) is what a future kernel-net port (e.g. the
//! dial-callback shape, ADR-004) would reuse, and its mapping is
//! pinned by the module's test.
+7 -6
View File
@@ -1,10 +1,11 @@
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs`
//! — extraction; ownership per ADR-013, see the crate root's
//! Forked from fast-socks5 v1.0.0 (upstream `src/util/target_addr.rs`;
//! extraction; ownership per ADR-013, see the crate root's
//! `mod.rs`).
//!
//! Upstream carries `TargetAddr`, the address codec (`read_address`),
//! and the `resolve_dns` helper; only the codec rode the extraction
//! (DNS resolution is the cut net region — `tokio::net::lookup_host`
//! (DNS resolution is the cut net region, since
//! `tokio::net::lookup_host`
//! is not in the wasm-clean graph). The `resolve_dns` method is cut
//! with its two `AddrError` variants (`DNSResolutionFailed`,
//! `NoDNSRecords`).
@@ -110,7 +111,7 @@ impl TargetAddr {
/// The blocking `ToSocketAddrs` form: IP targets resolve directly;
/// domain targets cannot be resolved here (domain resolution is the
/// egress's job — ADR-012) and error.
/// egress's job, per ADR-012) and error.
impl std::net::ToSocketAddrs for TargetAddr {
type Iter = IntoIter<SocketAddr>;
@@ -210,7 +211,7 @@ impl ToTargetAddr for &TargetAddr {
pub enum Addr {
V4([u8; 4]),
V6([u8; 16]),
// `String` — validated UTF-8 at the codec boundary (`read_address`
// `String`, validated UTF-8 at the codec boundary (`read_address`
// converts before constructing).
Domain(String),
}
@@ -218,7 +219,7 @@ pub enum Addr {
/// This function is used by the client & the server.
///
/// The domain arm's allocation is bounded by construction: the wire
/// length field is one byte, so the claimed length is at most 255 —
/// length field is one byte, so the claimed length is at most 255;
/// the attacker-controlled byte cannot ask for more. Any change that
/// widens that field (an FRAG-style extension) requires an explicit
/// cap here before the allocation.
+32 -32
View File
@@ -1,5 +1,5 @@
//! The ASSOCIATE datagram frame codec (ADR-003): the crate's one
//! framed stage — `[len: u16 BE][addr + data]` per datagram, both
//! framed stage, `[len: u16 BE][addr + data]` per datagram, both
//! directions of the channel `BiStream`. CONNECT/BIND are raw
//! pass-through (no framing). Normative WHAT: `wire.md` §The Data
//! Plane; the binary contract: `bast.md` (`AssociateFrame`). The
@@ -7,18 +7,18 @@
//! `frame_datagram`/`DatagramReader` (same F-2 behavior; no
//! dependency on it).
//!
//! The inner bytes are the RFC 1928 address encoding — exactly
//! The inner bytes are the RFC 1928 address encoding, exactly
//! `TargetAddr::to_be_bytes()` (ATYP + addr + port; the RFC UDP
//! header minus RSV/FRAG, which ride only vanilla front doors,
//! ADR-003) — followed by the datagram payload.
//! ADR-003), followed by the datagram payload.
//!
//! **Framing is applied exactly once, by this layer.** The egress
//! seam speaks *inner* bytes (`addr + data`, no length prefix —
//! seam speaks *inner* bytes (`addr + data`, no length prefix;
//! `Egress::next_reply` returns inner bytes); the producer's frame
//! writer adds the single length prefix, and the consumer's send path
//! frames via [`frame`] exactly once. The double-framing bug class
//! POC #2 caught (an egress framing its replies, the producer
//! prefixing again — the client parsed the length field as ATYP
//! prefixing again, the client parsed the length field as ATYP
//! `0x00`) is an API-design invariant: the wire layer owns the length
//! prefix; nothing else adds one.
//!
@@ -26,7 +26,7 @@
//! field covers `addr + data`, not the payload alone: a frame is
//! rejected ([`WireError::Oversize`], at frame time) whenever
//! `len(addr) + len(payload) > 65535`. The UDP payload bound alone
//! (65507) does NOT fit u16 unconditionally — a 65507-byte datagram
//! (65507) does NOT fit u16 unconditionally: a 65507-byte datagram
//! to a 255-character domain is 65507 + 259 = 65766 frame bytes. Max
//! payload per address form: 65276 (domain, 255 chars), 65516 (IPv6),
//! 65528 (IPv4). [`addr_encoding_len`] exposes the per-address
@@ -34,9 +34,9 @@
//! u16 arithmetic is used on the payload bound.
//!
//! **The F-2 invariant, at payload level.** A zero-length payload is
//! a legal empty datagram — a real datagram, never an EOF signal:
//! a legal empty datagram (a real datagram, never an EOF signal):
//! [`frame`] encodes it and [`DatagramReader`] decodes it as
//! `(addr, empty)`. (`len == 0` — zero inner bytes — is unreachable
//! `(addr, empty)`. (`len == 0`, zero inner bytes, is unreachable
//! from a compliant peer, since `addr` is always ≥ 7 bytes; a wire
//! `len` of 0 is protocol garbage and surfaces as
//! [`WireError::EmptyFrame`].)
@@ -44,11 +44,11 @@
//! **EOF layering.** EOF is the channels-level sentinel on the
//! `BiStream`, outside this codec: the codec never emits zero-length
//! reads, so the sentinel and the datagram stream never collide (an
//! empty datagram and a zero-byte read are different shapes — the
//! reason boundary-preserving framing is mandatory for the datagram
//! empty datagram and a zero-byte read are different shapes; that is
//! why boundary-preserving framing is mandatory for the datagram
//! stage at all). A stream end mid-frame is the session's fail-loud
//! truncated-datagram condition ([`DatagramReader::is_mid_frame`] is
//! the teardown diagnostic); the codec itself only buffers — it never
//! the teardown diagnostic); the codec itself only buffers; it never
//! errors on truncation and never yields a partial frame.
use bytes::{Buf, BufMut, Bytes, BytesMut};
@@ -60,7 +60,7 @@ use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
/// The u16 length-field bound on a frame's inner bytes (`addr + data`):
/// frames whose address encoding plus payload exceed this are rejected
/// at frame time (ADR-003 — never a wire overflow). The joint bound,
/// at frame time (ADR-003: never a wire overflow). The joint bound,
/// not a payload bound: see the module docs.
pub const MAX_INNER_LEN: usize = u16::MAX as usize;
@@ -69,12 +69,12 @@ const FRAME_LEN_FIELD: usize = 2;
/// Codec errors. Two families:
///
/// - **Frame time** ([`frame`]): [`WireError::Oversize`] (the joint
/// bound — `len(addr) + len(payload) > 65535` would wrap the u16
/// bound; `len(addr) + len(payload) > 65535` would wrap the u16
/// length field) and [`WireError::Address`] (the address encoding
/// refused — e.g. a domain longer than 255 bytes).
/// refused, e.g. a domain longer than 255 bytes).
/// - **Decode time** ([`DatagramReader::feed`], per completed frame):
/// [`WireError::EmptyFrame`], [`WireError::BadAtyp`],
/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`] — all
/// [`WireError::ShortAddress`], [`WireError::DomainNotUtf8`]: all
/// protocol-garbage shapes a compliant peer never produces. A bad
/// ATYP is its own variant so the producer stage can fail the
/// association loud (`udp_undecodable_frame_ends_association`,
@@ -87,24 +87,24 @@ const FRAME_LEN_FIELD: usize = 2;
#[derive(Debug, Error)]
pub enum WireError {
/// A frame's inner bytes (`addr + data`) exceed
/// [`MAX_INNER_LEN`] — the u16 length field would wrap. Rejected
/// [`MAX_INNER_LEN`]; the u16 length field would wrap. Rejected
/// at frame time; carries `(total, max)`.
#[error("datagram frame too large for u16 length prefix: {0} bytes of addr + data (max {1})")]
Oversize(usize, usize),
/// The address could not be encoded (`TargetAddr::to_be_bytes`
/// refused — e.g. `AddrError::DomainLenTooLong`).
/// refused, e.g. `AddrError::DomainLenTooLong`).
#[error("address encode: {0}")]
Address(#[from] AddrError),
/// A completed frame declared `len == 0` — zero inner bytes, no
/// A completed frame declared `len == 0` (zero inner bytes, no
/// address at all. Unreachable from a compliant peer (`addr` is
/// always ≥ 7 bytes); protocol garbage.
#[error("empty frame: no address bytes (len == 0 is unreachable from a compliant peer)")]
EmptyFrame,
/// The frame's first byte is not a known ATYP (`0x01`/`0x03`/
/// `0x04`) — post-reply control bytes parse as exactly this (the
/// `0x04`); post-reply control bytes parse as exactly this (the
/// frame's first byte is ATYP). The loud-fail shape:
/// `udp_undecodable_frame_ends_association`, POC #2.
#[error("unknown ATYP {0:#x}: protocol garbage")]
@@ -122,7 +122,7 @@ pub enum WireError {
}
/// The RFC 1928 address encoding's length for `addr` (ATYP + addr +
/// port — exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for
/// port, exactly `TargetAddr::to_be_bytes()`'s size): 7 bytes for
/// IPv4, 19 for IPv6, `4 + domain.len()` for domain form (259 max).
/// Callers compute the joint bound with it: a payload is framable for
/// `addr` iff `addr_encoding_len(addr) + payload.len() <= MAX_INNER_LEN`.
@@ -137,10 +137,10 @@ pub fn addr_encoding_len(addr: &TargetAddr) -> usize {
/// Frame one datagram: `[len: u16 BE][addr + data]` where `addr +
/// data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed
/// by `payload`. One frame per datagram; the single length prefix on
/// this wire (framing-once — see the module docs). A zero-length
/// this wire (framing-once; see the module docs). A zero-length
/// payload is a legal empty datagram (the F-2 invariant, at payload
/// level). Rejects [`WireError::Oversize`] at frame time when
/// `len(addr) + len(payload) > 65535` — the joint bound; the payload
/// `len(addr) + len(payload) > 65535`, the joint bound; the payload
/// bound alone (65507) does not fit u16 unconditionally.
pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> {
let addr_bytes = addr.to_be_bytes()?;
@@ -158,7 +158,7 @@ pub fn frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes, WireError> {
Ok(buf.freeze())
}
/// Frame pre-encoded inner bytes (`addr + data` — exactly what
/// Frame pre-encoded inner bytes (`addr + data`, exactly what
/// [`crate::egress::Egress::next_reply`] returns) as one
/// `[len: u16 BE][inner]` frame. The reply-direction half of the
/// framing-once invariant: the egress speaks inner bytes (no length
@@ -177,14 +177,14 @@ pub fn frame_inner(inner: &[u8]) -> Result<Bytes, WireError> {
/// Incremental datagram decoder over the framed stage's read
/// direction. Feed it whatever `read()` yields; it reassembles
/// `[len: u16 BE][addr + data]` frames across chunk boundaries — a
/// `[len: u16 BE][addr + data]` frames across chunk boundaries: a
/// frame may be split across chunks, several frames may share one
/// chunk, and partial headers wait for more bytes. Each completed
/// frame is decoded into `(TargetAddr, Bytes)` — the addr sub-layout
/// frame is decoded into `(TargetAddr, Bytes)`: the addr sub-layout
/// (ATYP determines the address length) is split off, the remainder
/// is the datagram payload.
///
/// Reassembly is infallible (see [`WireError`] docs — the oversize
/// Reassembly is infallible (see the [`WireError`] docs: the oversize
/// bound is enforced at frame time); the only error per frame is the
/// addr decode (the garbage shapes). The reader keeps going after a
/// decode error (the frame's bytes are consumed either way); the
@@ -210,7 +210,7 @@ impl DatagramReader {
}
/// Feed raw bytes from the read half. Returns every frame that
/// completed with this feed, in order — zero or more per chunk.
/// completed with this feed, in order, zero or more per chunk.
/// Buffers partial frames across chunk boundaries (bounded by the
/// u16 declared length). Infallible reassembly; per-frame addr
/// decode errors are the [`WireError`] decode variants.
@@ -249,13 +249,13 @@ impl DatagramReader {
}
/// `true` while a partial frame is in flight (the teardown
/// diagnostic — a clean peer never ends the stream mid-frame; a
/// diagnostic: a clean peer never ends the stream mid-frame; a
/// stream end while this is set is the session's fail-loud
/// truncated-datagram condition, never a silent partial
/// datagram).
///
/// Kept public for the anticipated batch-2 consumer — a front-door
/// bridge driving its own reader loop (the doors) — so a later
/// Kept public for the anticipated batch-2 consumer (a front-door
/// bridge driving its own reader loop, the doors), so a later
/// dead-surface sweep does not flag it (review-002 N-9).
pub fn is_mid_frame(&self) -> bool {
self.current.is_some()
@@ -273,7 +273,7 @@ impl DatagramReader {
/// Decode one frame's inner bytes (`addr + data`, the length prefix
/// already consumed) into `(addr, payload)`. The first byte is ATYP;
/// the address encoding is parsed by hand over the borrowed slice —
/// the address encoding is parsed by hand over the borrowed slice;
/// the same sub-layout the forked `read_address` accepts in-stream,
/// mirrored synchronously (the stage's decode must not be async over
/// a borrowed frame). RSV/FRAG cannot appear (ADR-003: they do not
@@ -567,7 +567,7 @@ mod tests {
fn frame_and_frame_inner_produce_identical_wires() {
// The reply path (frame_inner over pre-encoded inner bytes)
// and the forward path (frame over addr + payload) agree
// byte-for-byte — one codec, two callers.
// byte-for-byte (one codec, two callers).
let addr = domain("example.com", 53);
let payload = b"payload";
let mut inner = addr.to_be_bytes().unwrap().to_vec();
@@ -1,6 +1,6 @@
---
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)"
name: "ADR-014: the idle-timeout posture for the pre-command handshake and the association (review-002 C-2)"
status: completed
depends_on: []
scope: narrow
@@ -12,38 +12,38 @@ level: implementation
## Description
Review 002's C-2 (major): the pre-command RFC handshake and the
association have **no timeout surface** — a silent channel holds a
association have **no timeout surface**: a silent channel holds a
spawned task and reader state indefinitely (bounded memory,
unbounded time). The alkcall 10s establishment bound governs the
*open-op call*, not the in-stream conversation
(`src/producer.rs:183-191` parks on `accept_bi` → `accept_no_auth`
→ `read_command` with no timeout; the datagram stage's loop exits
only on EOF/error/data). BIND's quiet wait got an explicit decision
(ADR-007 — teardown-governed, "FTP-length waits stay legal"); the
(ADR-007: teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the
channel" — implicit, never decided.
channel": implicit, never decided.
The mitigations are real (the 256-channel cap bounds the count,
memory per idle channel is bounded, teardown fires on channel
close — nothing leaks, nothing grows), but the aggregate is: any
close; nothing leaks, nothing grows), but the aggregate is: 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`) are about to route *vanilla
clients* through these same phases via the front doors, where the
peer is a kernel socket often outside alkcall's channel-cap regime
entirely — slowloris-shaped holds become the assembly layer's
entirely, so slowloris-shaped holds become the assembly layer's
problem, and the protocol layer is where a timeout seam would live.
**The deliverable is a decision, not code** — ADR-014
**The deliverable is a decision, not code**: ADR-014
(`docs/architecture/decisions/014-*.md`), landing *before* the
`local` doors exist. Decide between the review's two options:
1. **Idle timeout is the substrate's/assembly's job** — the
1. **Idle timeout is the substrate's/assembly's job**: the
protocol layer never times out (consistent with "no in-band
control vocabulary" and association-lifetime-equals-channel);
record the DoS posture explicitly (the cap bounds it; doors
apply their own accept timeouts). Cheapest, defensible.
2. **A producer-side idle bound** — an optional per-session
2. **A producer-side idle bound**: an optional per-session
inactivity timeout at the pump level (config on
`Socks5ProducerBuilder`), enforced by racing the handshake/stage
reads with a sleep timer; a timeout = clean EOF-shaped teardown,
@@ -54,11 +54,11 @@ problem, and the protocol layer is where a timeout seam would live.
The ADR must state, whichever option wins: the BIND quiet wait's
existing decision (ADR-007) and how the new decision relates to it
(same regime or different — and why the difference is principled);
(same regime or different, and why the difference is principled);
the DoS posture in the doors' terms (what a `local` door's accept
path must do if the protocol layer does not); and whether the
decision is revisitable post-batch-2 or is itself a one-way door
(it should not be — a timeout seam is additive — but say so).
(it should not be, since a timeout seam is additive, but say so).
Also update `docs/architecture/open-questions.md` if C-2 was
tracked there, and the affected spec lines (wire.md §UDP
@@ -77,7 +77,7 @@ one.
- [x] The doors' DoS posture is recorded (what the assembly layer
must do if the protocol layer does not)
- [x] The affected spec lines (wire.md, producer.md) carry the
decided posture — no implicit residue
decided posture; no implicit residue
- [x] The decision lands before any `local`/`composed` door work
(the batch-2 tasks may depend on this one)
- [x] `cargo doc --no-deps` clean if any doc-comment carries the
@@ -86,28 +86,28 @@ one.
## References
- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the
finding — the task's source of truth)
finding, the task's source of truth)
- docs/architecture/decisions/007-bind-in-base.md (the quiet wait's
existing decision — the contrast case)
existing decision, the contrast case)
- docs/architecture/decisions/008-provider-defaults.md,
009-feature-gates.md (the batch-2 provider posture the decision
precedes)
- docs/architecture/wire.md §UDP ASSOCIATE (association lifetime =
channel lifetime — the contract the decision qualifies)
- The POC findings (the stage's measured shapes — the
channel lifetime; the contract the decision qualifies)
- The POC findings (the stage's measured shapes, the
"sufficient for relay postures" lineage)
## Notes
> Filled by the implementation agent (2026-09-24):
- **Option 1 won** — the protocol layer never times out; every phase
- **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,
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
@@ -115,24 +115,24 @@ one.
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.
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 —
`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
- **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
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
- **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
@@ -143,19 +143,19 @@ one.
ADR-014 landed
(`docs/architecture/decisions/014-idle-timeout-posture.md`, status
accepted, 2026-09-24), deciding review 002 C-2: **option 1 — the
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
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 —
(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
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
@@ -1,6 +1,6 @@
---
id: architecture/oq-sk2-06-wasm-adapter-story
name: Track — OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter)
name: Track for OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter)
status: pending
depends_on: []
scope: single
@@ -13,16 +13,16 @@ level: research
External-trigger tracker for OQ-SK2-06 (`docs/architecture/
open-questions.md`): the sandboxed-wasm-consumer adapter story. This
task is NOT actionable work — it tracks whether the external
task is NOT actionable work; it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete sandboxed-adapter use case
exists — a TS/Python (or other) adapter embedding the alksocks
exists: a TS/Python (or other) adapter embedding the alksocks
consumer session in a wasm runtime.
**Trigger ownership (checked 2026-09-28)**: external by verified
default — no planned suite-internal surface names a sandboxed
default: no planned suite-internal surface names a sandboxed
alksocks consumer (see the OQ's "Trigger ownership" bullet for the
check). If a product surface later names one, convert this tracker
to an owned dependency in the same round.
@@ -40,7 +40,7 @@ When the condition arrives:
## Verification
N/A (tracking task — trivial risk, no code).
N/A (tracking task; trivial risk, no code).
## Out of scope
@@ -1,6 +1,6 @@
---
id: architecture/oq-sk2-07-virtual-subnet-helper
name: Track — OQ-SK2-07 unblock condition (virtual-subnet deployment)
name: Track for OQ-SK2-07 unblock condition (virtual-subnet deployment)
status: pending
depends_on: []
scope: single
@@ -13,13 +13,13 @@ level: research
External-trigger tracker for OQ-SK2-07 (`docs/architecture/
open-questions.md`): the virtual-subnet translation-layer helper.
This task is NOT actionable work — it tracks whether the external
This task is NOT actionable work; it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete deployment wants identity-scoped
virtual subnets (proxied resources IP-addressable per identity) —
the shape ADR-012 resolved as assembly-layer policy over the dial
virtual subnets (proxied resources IP-addressable per identity), the
shape ADR-012 resolved as assembly-layer policy over the dial
callback.
## Work
@@ -36,7 +36,7 @@ When the condition arrives:
## Verification
N/A (tracking task — trivial risk, no code).
N/A (tracking task; trivial risk, no code).
## Out of scope
@@ -1,6 +1,6 @@
---
id: implementation/assembly-integration-suite
name: Assembly — the cross-cutting integration suite + docs sync
name: "Assembly: the cross-cutting integration suite + docs sync"
status: completed
depends_on: [implementation/door-udp, implementation/local-backend-producer, implementation/composed-accept]
scope: moderate
@@ -23,36 +23,36 @@ Deliverables:
- **The full `-D` story end to end**: a vanilla tun2proxy-shaped
client → the cred-bearing TCP door → the channels-native
session → the producer with `local` providers → real echo
targets — CONNECT + UDP (the RFC-header'd datagram through the
targets: CONNECT + UDP (the RFC-header'd datagram through the
UDP door) + BIND (the vanilla BIND conversation through the
door) all through one door process against one producer
registration (the one-registration-three-commands regression,
now through the doors).
- **The composed story end to end**: the producer with
`composed::direct_egress` + `composed::bindfirst_accept` —
`composed::direct_egress` + `composed::bindfirst_accept`:
CONNECT through the direct op, ASSOCIATE through the lazy
table, BIND through the bind-first listener (the ADR-008
composed variant set, all three commands).
- **Mixed assembly**: one producer registration serving
channels-native sessions AND door clients AND composed seams
concurrently (the producer/consumer-roles-not-sides posture —
concurrently (the producer/consumer-roles-not-sides posture;
both shapes on one connection).
- **Teardown matrix, assembly edition**: door-client disconnect
tears the association/channel down (no leaks — the channel
tears the association/channel down (no leaks; the channel
count witnesses); the idle bounds fire; fresh sessions succeed
immediately after each teardown (no connection poisoning).
- **The wasm gates re-verified**: default + `--features composed`
wasm checks green (the `local` feature is non-wasm by design —
wasm checks green (the `local` feature is non-wasm by design;
verify it *fails* to build on wasm, the alktty posture, and
record the observed error in Notes).
2. **Docs sync** (the architecture-sync check):
- `docs/architecture/README.md` Current State + the doc statuses
reflect the landed assembly layer (the "no `local`/`composed`
code exists yet" lines updated).
- `lib.rs` crate docs updated (the "not yet present" paragraph —
- `lib.rs` crate docs updated (the "not yet present" paragraph;
the doors/providers are landed now).
- Any spec drift the implementation surfaced reconciled in the
same change — never silently diverged (the batch-1 discipline).
same change, never silently diverged (the batch-1 discipline).
3. **Full verification sweep** (the batch gate's input):
`cargo test`, `cargo test --all-features`, clippy
`--all-targets --all-features -- -D warnings`, `cargo fmt
@@ -66,7 +66,7 @@ Deliverables:
commands
- [x] The composed variant set pinned end to end (all three commands)
- [x] Mixed assembly (doors + channels-native + composed) concurrent
on one producer — pinned
on one producer (pinned)
- [x] The assembly teardown matrix pinned (door disconnects, idle
bounds, fresh-session-after-teardown)
- [x] All four verification targets green (default, all-features,
@@ -76,7 +76,7 @@ Deliverables:
## References
- docs/architecture/overview.md (the feature gates + module map —
- docs/architecture/overview.md (the feature gates + module map;
the landed-shape check), consumer.md §The Front Doors, producer.md
§Producer-Side Local Backend, §Wiring
- docs/architecture/decisions/005/007/008/009/014
@@ -91,24 +91,24 @@ Deliverables:
- **The spec drift the implementation surfaced (one real drift,
reconciled in `src/composed/mod.rs`):** `ComposedEgress::next_reply`
returned the association-end `Err` ("all tunnel relays ended") on a
*fresh* association too — the lazy table starts with `live_relays ==
*fresh* association too: the lazy table starts with `live_relays ==
0`, so the producer's datagram stage (which parks on `next_reply`
when no datagram has arrived) ended every quiescent association at
birth. The composed-egress suite drove the egress send-first and
never hit it; the assembly suite's `AssociateSession` (open → the
stage parks → the first datagram) did. Fix: an `ever_opened` flag —
stage parks → the first datagram) did. Fix: an `ever_opened` flag;
the ended-relay `Err` fires only once at least one tunnel existed
and every relay has ended; a never-opened table parks (the readiness
contract's `Pending`, the channel branch governs). The landed spec
wording (producer.md: "`Pending` when nothing is ready") already
stated the correct contract — the implementation was the divergent
stated the correct contract; the implementation was the divergent
side. Regression-pinned in `tests/composed-egress.rs`
(`fresh_association_parks_before_any_tunnel_is_opened`).
- **Two assembly-shape findings recorded in the tests (not code
changes — the landed contracts are correct):**
changes; the landed contracts are correct):**
- The cred-bearing door's `TokenClient::wrap` takes the client's
channel-0 `CallConnection` once (the door-only contract,
door-tcp's Notes) — the UDP door's association (and any other
door-tcp's Notes); the UDP door's association (and any other
consumption shape) rides its own channels connection to the same
producer registration. The suite pins the assembly story as "one
registration, many connections" (consumer.md reconciled).
@@ -116,7 +116,7 @@ Deliverables:
door shape (the cred-bearing TCP door's RFC half consumes the
greeting + one command; the producer-side `ProducerDoor`'s
`serve_socks5_over` likewise serves one command per accepted
socket — a second command's bytes ride the first session's data
socket; a second command's bytes ride the first session's data
plane). The vanilla one-command-per-connection shape is pinned by
the suite's structure (each command opens its own door
connection).
@@ -125,12 +125,12 @@ Deliverables:
test bind, the accept queue + loop, the channel-set probe, the
identities) moved from `tests/composed-accept.rs` into
`tests/harness.rs` (single-sourced; `composed-accept.rs` consumes
the same shapes via the harness re-exports — its test bodies are
the same shapes via the harness re-exports (its test bodies are
unchanged in behavior). The five-gate composed identity
(`composed_full_opener_identity` — `tunnel:open` + `tunnel:direct`
(`composed_full_opener_identity`: `tunnel:open` + `tunnel:direct`
+ `tunnel:forwarded` + `socks5:open` + `socks5:bind`) is new.
- **The wasm gate, observed**: `cargo check --features local --target
wasm32-unknown-unknown` fails as designed (the alktty posture) —
wasm32-unknown-unknown` fails as designed (the alktty posture);
the first error is `error: This wasm target is unsupported by mio.
If using Tokio, disable the net feature.` (mio v1.2.3, via
`tokio/net` which `local` enables), followed by the mio `sys`
@@ -150,14 +150,14 @@ Deliverables:
> Filled on completion (2026-09-25).
Landed the batch-2 closing task: `tests/assembly-integration.rs` —
Landed the batch-2 closing task: `tests/assembly-integration.rs`,
seven cross-cutting integration tests pinning the whole assembly layer
end to end (feature `local` + `composed`):
1. **The full `-D` story** (`full_dash_d_story_...`): a vanilla
tun2proxy-shaped client through the cred-bearing RFC 1929 TCP door
+ the UDP door against one producer registration with the `local`
providers and real echo targets — CONNECT + BIND (the app server
providers and real echo targets: CONNECT + BIND (the app server
dials the advertised address; reply#2 carries the real peer) +
ASSOCIATE (the RFC-header'd datagram through the relay socket)
live simultaneously, each exercised mid-life of the others, the
@@ -165,9 +165,9 @@ end to end (feature `local` + `composed`):
sessions succeeding after (no connection poisoning).
2. **The composed story** (`composed_story_...`): the producer with
`composed::direct_egress` + `composed::bindfirst_accept` + a real
TCP dial — CONNECT, ASSOCIATE (the lazy table; the direct-op dial
TCP dial: CONNECT, ASSOCIATE (the lazy table; the direct-op dial
count witnesses the lazy open), BIND (the real `bound` + the real
`peer` + the pass-through plane) — all three commands, all live.
`peer` + the pass-through plane); all three commands, all live.
3. **Mixed assembly, twice**: channels-native sessions + door clients
concurrent on one registration (the doors on their own
connections), and composed seams beside channels-native sessions
@@ -184,12 +184,12 @@ end to end (feature `local` + `composed`):
NoAuth raw-pipe door resolves at the producer's seam (ADR-012).
The reconciled drift: `ComposedEgress`'s never-opened-table association
end (fixed in `src/composed/mod.rs` — the `ever_opened` guard;
end (fixed in `src/composed/mod.rs`, the `ever_opened` guard;
regression-pinned in `tests/composed-egress.rs`). Docs synced:
`README.md` Current State (the assembly layer landed; both suites
named), `lib.rs` crate docs (the assembly-layer paragraph + both
suites named), `consumer.md` §Door variants (the `TokenClient`
door-only contract + one-command-per-connection posture reconciled —
door-only contract + one-command-per-connection posture reconciled,
the drift the suite surfaced, recorded in Notes). Harness: the
composed-accept machinery single-sourced into `tests/harness.rs` (the
N-5 discipline).
@@ -200,4 +200,4 @@ warnings` + `cargo clippy --all-targets --all-features -- -D warnings`
clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean (0
warnings), both wasm checks green (default + `--features composed`,
check + clippy), and the `local`-on-wasm build failure observed +
recorded (the alktty posture — mio's unsupported-wasm error).
recorded (the alktty posture: mio's unsupported-wasm error).
+30 -29
View File
@@ -1,6 +1,6 @@
---
id: implementation/composed-accept
name: Composed — the bind-first accept (the listener on a listen resource)
name: "Composed: the bind-first accept (the listener on a listen resource)"
status: completed
depends_on: [implementation/composed-egress]
scope: moderate
@@ -14,7 +14,7 @@ level: implementation
The `composed` feature's accept provider (ADR-008's composed
variants; ADR-007 variant 2): the BIND accept whose listener lives on
an alktunnels 0.2.0 bind-first listen resource
(`register_tunnel_bindfirst_openable` + `BindFirstInjections`) —
(`register_tunnel_bindfirst_openable` + `BindFirstInjections`),
per-listener ACL for free, hub-relayable. **Vanilla-fidelity with no
stand-ins since the graduation** (ADR-007): reply#1 advertises the
real OS-chosen `bound` (the open-op reply's `bound` field, alkcall
@@ -23,15 +23,15 @@ op's `peer` param).
Deliverables:
1. **`src/composed/` additions** (feature `composed`) — the composed
1. **`src/composed/` additions** (feature `composed`): the composed
accept, named per producer.md §Wiring:
- `bindfirst_accept(...)` → `BindAcceptFactory`: the POC #5
`composed_bind_accept` shape **updated to the graduation** —
`composed_bind_accept` shape **updated to the graduation**:
the POC's pinned workaround (reply#1 first, the tunnel open
rides the accept future; the factory carries an expected
`bind_addr`) is **retired** (ADR-007 §Consequences). The landed
shape: the factory opens the bind-first listen channel
(`TunnelListener::open` — the reply projection surfaces
(`TunnelListener::open`; the reply projection surfaces
`bound`), reply#1 advertises the real `bound`, and the accept
future correlates the forwarded sessions (the `listen` token →
the adopted forwarded sessions the assembly correlates; the
@@ -45,7 +45,7 @@ Deliverables:
ordinary adopted sessions on the manager; the listener does not
own the receipt queue"). The provider owns: the
`register_tunnel_forwarded_openable` serving side (the opener
half — the SOCKS5 producer's connection must serve the
half: the SOCKS5 producer's connection must serve the
forwarded op), the token→waiter map, and the per-forwarded
pump. Design the wait shape against the landed alkcall 0.8.0
adopt surface (`manager.adopt_channel`) and record it in Notes.
@@ -53,8 +53,8 @@ Deliverables:
accepted halves must be `Box<dyn AsyncRead + Send + Sync +
Unpin>` (`BindTargetHandle`'s payloads); alktunnels' `Taken
Halves` yields `Send + Unpin` only. The owner-wrapper rides
**inside the handle payloads** (egress.rs's documented posture)
— the wrapper owns the adopted channel session so dropping the
**inside the handle payloads** (egress.rs's documented posture):
the wrapper owns the adopted channel session so dropping the
handle drops everything (the teardown-at-birth discipline,
compiler-enforced). This is the load-bearing wrapper: design it
once (with composed-egress's needs), pin it with a test.
@@ -64,15 +64,15 @@ Deliverables:
variant's.
- **The establishment bound covers only the open-op round trip**
(ADR-007): the accept wait lives in the teardown-governed pump
phase — the quiet wait's `select!` drop of the accept future IS
phase: the quiet wait's `select!` drop of the accept future IS
the client-gone teardown; the listener (inside the future)
closes with it. The forwarded-op correlation must survive a
client-gone drop without leaking the listener (the accept
future owns it end-to-end — the seam contract).
future owns it end-to-end, which is the seam contract).
- Module doc: names the ADRs (007/008), the graduation's
vanilla-fidelity win, the correlation shape, the owner-wrapper.
2. **Tests** (`tests/composed-accept.rs`): the full composed BIND
story over channels — a bind-first tunnel producer
story over channels: a bind-first tunnel producer
(`register_tunnel_bindfirst_openable` + injections), the SOCKS5
producer with `composed::bindfirst_accept(...)`, a consumer BIND
session: reply#1 carries the real `bound`, an app server dials
@@ -89,14 +89,14 @@ Deliverables:
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
clean; `cargo fmt --check` clean
- [ ] The graduation's shape lands (reply#1 = the real `bound`;
reply#2 = the forwarded op's real `peer`) — no expected-addr
stand-in — pinned by test
reply#2 = the forwarded op's real `peer`, no expected-addr
stand-in), pinned by test
- [ ] The owner-wrapper rides inside the handle payloads (Sync
halves; teardown structural) — pinned by test
halves; teardown structural), pinned by test
- [ ] The client-gone teardown releases the listener (the accept
future owns it end-to-end) — pinned by drop-witness test
future owns it end-to-end), pinned by drop-witness test
- [ ] The forwarded-op serving side + correlation land; per-accept
isolation — pinned by tests
isolation, pinned by tests
## References
@@ -106,10 +106,10 @@ Deliverables:
- docs/architecture/producer.md §Wiring
(`composed::bindfirst_accept`), §The Three Seams
- POC reference: `/workspace/alksocks-bind-poc/src/accept.rs`
(`composed_bind_accept` — the superseded-in-shape workaround, the
(`composed_bind_accept`, the superseded-in-shape workaround, the
owner-wrapper's first witness)
- Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle`
— the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_
- Landed surfaces: `src/egress.rs` (`BindAccept`/`BindTargetHandle`,
the contracts), alktunnels 0.2.0 (`register_tunnel_bindfirst_
openable`, `BindFirstInjections`, `TunnelListener`,
`register_tunnel_forwarded_openable`, `ForwardedHandler`)
@@ -125,26 +125,27 @@ Deliverables:
removes the entry on the arriving forwarded open and delivers
`ForwardedSession { far duplex halves, peer }`. A dropped receiver
(client-gone) or a miss makes the handler drop the forwarded
channel stream — teardown-at-birth, R-02 (the inline `pump_bidi`
channel stream: teardown-at-birth, R-02 (the inline `pump_bidi`
await in `register_openable`'s wrapper then reaps the entry).
- **The Sync-bound wrinkle**: alktunnels' halves are `Send + Unpin`
only; the landed `BindTargetHandle` payloads carry `+ Sync`. Two
adapters pin the no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`;
contention never occurs — one pump task at a time): `SyncHalves`
contention never occurs, since only one pump task runs at a time):
`SyncHalves`
(read half) and `OwnedWrite` (write half + the owner inside).
- **The owner-wrapper rides inside the payloads**
(`ForwardedOwner`): the write payload owns the `TunnelListener` —
(`ForwardedOwner`): the write payload owns the `TunnelListener`;
dropping the handle drops the listener → the listen channel EOFs →
the far teardown cascades (teardown structural,
compiler-enforced). The edition-2021 disjoint-capture trap (an
earlier draft captured `listener.channel_id`, dropping the
listener at the factory's return — EOF at birth) is why the whole
listener at the factory's return, that is EOF at birth) is why the whole
struct is rebound into the accept future.
- **The three-party bridge pump**: the forwarded handler bridges the
forwarded `BiStream` to a local duplex pair whose far end travels
into the accept future's payloads. The bridge completes on EITHER
direction's EOF (a `pump_bidi`-then-drop, not a per-direction
shutdown cascade — a three-party half-close cycle would deadlock:
shutdown cascade; a three-party half-close cycle would deadlock:
each pump's completion-shutdown needs the next hop's EOF, and the
chain closes on itself). Dropping everything EOFs the SOCKS5
producer-side pump, ends the per-accept pump, and tears the
@@ -154,7 +155,7 @@ Deliverables:
forwarded channel id on the producer-side manager; that entry's
reap rides the per-adopter contract + transport EOF, NOT the
listener's teardown (alktunnels ADR-005; upstream's own bind-first
suites make the same concession — `producer_bindfirst.rs`
suites make the same concession: `producer_bindfirst.rs`
`no_stray_channels` documents the listen channel, and the
forwarded entries are adopted sessions owned by their adopter).
`assert_only_channels` therefore treats odd producer-side ids as
@@ -162,8 +163,8 @@ Deliverables:
- **Forwarded-open failure semantics**: a FORBIDDEN forwarded open is
per-accept isolated AT THE TUNNEL LAYER (the producer's accept loop
drops that connection and keeps serving; the alktunnels
producer-suite pin). The SOCKS5 accept future stays parked — the
SOCKS5 producer never learns of the tunnel-layer failure — so no
producer-suite pin). The SOCKS5 accept future stays parked; the
SOCKS5 producer never learns of the tunnel-layer failure, so no
reply#2 arrives; the quiet wait is unaffected. Pinned by test.
## Summary
@@ -178,7 +179,7 @@ bookkeeping; the provider owns the forwarded-op serving half, the token→waiter
map, and the per-forwarded middle pump (either-EOF completion shape). The
Sync-bound wrinkle is met by `SyncHalves`/`OwnedWrite` (mutex Sync-proof, no
unsafe) with the owner (`ForwardedOwner`, the `TunnelListener`) riding inside
the write payload — dropping the handle drops everything (teardown structural,
the write payload: dropping the handle drops everything (teardown structural,
compiler-enforced). `tests/composed-accept.rs` pins: the full story (real
`bound`, real `peer`, raw pass-through round trip), the client-gone teardown
(drop-witness: the accept future owns the listener end-to-end; no leaked
@@ -187,4 +188,4 @@ isolation of a FORBIDDEN forwarded open, and the late-forwarded correlation
miss dropping the channel at birth. Verified: `cargo test`, `cargo test
--all-features`, `cargo check/clippy --target wasm32-unknown-unknown` (hard
gate), `cargo clippy --all-targets --all-features -- -D warnings`,
`cargo fmt --check` — all clean.
`cargo fmt --check`: all clean.
+33 -33
View File
@@ -1,6 +1,6 @@
---
id: implementation/composed-egress
name: Composed — the direct-op egress (lazy destination→tunnel table)
name: "Composed: the direct-op egress (lazy destination→tunnel table)"
status: completed
depends_on: []
scope: moderate
@@ -14,88 +14,88 @@ level: implementation
The `composed` feature's egress provider (ADR-008's composed
variants): the ASSOCIATE egress that dials each destination through
an alktunnels 0.2.0 direct-op channel (`TunnelSession::open_direct`,
`Substrate::Udp`) — per-target ACL for free (each destination rides
`Substrate::Udp`): per-target ACL for free (each destination rides
its own `tunnel:direct`-governed channel), the lazy
destination→tunnel table LRU-evicted at a cap (POC #2's
`ComposedEgress` shape).
Deliverables:
1. **`Cargo.toml`** — `composed = ["dep:alktunnels"]` (the only
1. **`Cargo.toml`**: `composed = ["dep:alktunnels"]` (the only
feature that adds the alktunnels dependency, ADR-009); alktunnels
pinned `"0.2.0"` (the sibling pin posture — convention 11's
pinned `"0.2.0"` (the sibling pin posture; convention 11's
deliberate-bump rule applies). Verify the composed graph stays
wasm-clean (alktunnels' default crate is wasm-clean; the composed
providers use only its ungated channels surface — the wasm check
providers use only its ungated channels surface, and the wasm check
with `--features composed` is a hard gate).
2. **`src/composed/mod.rs`** (feature `composed`) — the composed
2. **`src/composed/mod.rs`** (feature `composed`): the composed
providers, named per producer.md §Wiring:
- `direct_egress(...)` → `EgressFactory`: the POC #2
`ComposedEgress` shape ported to the landed seam types — a lazy
`ComposedEgress` shape ported to the landed seam types: a lazy
destination→tunnel table: each new destination opens one
alktunnels UDP-tunnel channel (`open_direct`, `Substrate::Udp`),
a relay task per tunnel drains inbound datagrams into a shared
queue, the write half stays in the table (LRU-capped; dropping
a write half EOFs the tunnel producer-side; the session's Drop
reaps the channel entry — alktunnels ADR-005).
reaps the channel entry (alktunnels ADR-005).
- **The Sync-bound wrinkle (the batch-1 review's seam contract vs
alktunnels' surface)**: the landed `TargetHandle`/`BindTarget
Handle` halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`,
but alktunnels' `TakenHalves` yields `Send + Unpin` only. The
composed providers need an **owner-wrapper inside the handle
payloads** (egress.rs's documented posture: "keep-alive state a
substrate needs rides inside the halves — a provider-side owner
substrate needs rides inside the halves; store a provider-side owner
wrapper"). For the egress the wrapper is the table entry's
session (the egress owns it — not a handle payload); for the
composed *accept* (the sibling task) the wrapper is load-bearing
— design it here, share it.
session (the egress owns it, not a handle payload); for the
composed *accept* (the sibling task) the wrapper is load-bearing,
so design it here and share it.
- **The readiness contract** (load-bearing): `next_reply` parks on
the shared inbound queue (`recv().await` — Pending until a
the shared inbound queue (`recv().await`, Pending until a
relay task delivers); a closed queue (all relays ended) returns
`Err` — never a hot `Ok(None)` (the POC's closed-queue arm; the
`Err`, never a hot `Ok(None)` (the POC's closed-queue arm; the
stage's zero-yield defense, review 002 R-1, is the backstop,
not the design). Replies carry *inner* bytes (`addr + data` —
not the design). Replies carry *inner* bytes (`addr + data`;
the reply address is the entry's recorded destination;
framing-once).
- **Per-datagram `send` is awaited inline** (the stage
interleaves sends with reply drains) — a slow egress applies
interleaves sends with reply drains), so a slow egress applies
backpressure to the association, not to other channels
(wire.md's note: "the batch-2 composed-egress author inherits
this bound knowingly"). The table's open cost rides the send
path (lazy open on first datagram — the POC's measured ~0.8 ms).
path (lazy open on first datagram; the POC's measured ~0.8 ms).
- The resource/target mapping: `open_direct` takes a
`SubstrateAddr` — the destination's `TargetAddr` renders to it
(IP forms direct; domain form passes through unresolved —
`SubstrateAddr`: the destination's `TargetAddr` renders to it
(IP forms direct; domain form passes through unresolved,
producer-side resolution, the ssh `-D` semantic; ADR-012).
Constructor config carries the LRU cap (the 256-channel default
cap bounds fan-out — ADR-010's posture; the cap is config, not
cap bounds fan-out, ADR-010's posture; the cap is config, not
wire).
- Module doc: names the ADRs (008/009), the per-target-ACL
benefit, the readiness contract, the framing-once invariant,
the backpressure bound.
3. **Tests** (`tests/composed-egress.rs`): the POC #2 shape end to
end over channels — a tunnel-producing peer (the alktunnels
end over channels: a tunnel-producing peer (the alktunnels
registry shape, `register_tunnel_direct_openable`), the composed
egress against it, datagram round-trips through the tunnel
(multiple destinations → multiple tunnels; the LRU eviction at
cap pinned by test); the readiness contract re-pinned (park-then-
datagram, no spin); the closed-queue → association-end shape;
framing-once (no double prefix — the POC #2 bug class).
framing-once (no double prefix, the POC #2 bug class).
## Acceptance Criteria
- [x] `cargo test` passes; `cargo test --all-features` passes
- [x] `cargo check --features composed --target wasm32-unknown-
unknown` clean — the composed graph is wasm-clean (hard gate)
unknown` clean; the composed graph is wasm-clean (hard gate)
- [x] `cargo clippy --all-targets --all-features -- -D warnings`
clean; `cargo fmt --check` clean
- [x] `alktunnels` pinned `"0.2.0"`; the dep rides `composed` alone
(default graph unchanged — `cargo tree` spot-check)
- [x] The lazy table + LRU eviction land per the POC shape — pinned
by test
(default graph unchanged, per the `cargo tree` spot-check)
- [x] The lazy table + LRU eviction land per the POC shape (pinned
by test)
- [x] The readiness contract honored (real async park; closed queue
→ `Err`, never a hot `Ok(None)`) — pinned by test
- [x] Framing-once (replies are inner bytes; no double prefix) —
→ `Err`, never a hot `Ok(None)`), pinned by test
- [x] Framing-once (replies are inner bytes; no double prefix),
pinned by test
- [x] Per-datagram send backpressure documented on the constructor
@@ -107,7 +107,7 @@ Deliverables:
- docs/architecture/producer.md §Wiring (`composed::direct_egress`),
§The Three Seams
- POC reference: `/workspace/alksocks-udp-poc/src/egress.rs`
(`ComposedEgress` — the table, the relay tasks, the closed-queue
(`ComposedEgress`: the table, the relay tasks, the closed-queue
posture)
- Landed surfaces: `src/egress.rs` (the seam contracts + the
owner-wrapper posture), alktunnels 0.2.0
@@ -119,14 +119,14 @@ Deliverables:
> Landed as `src/composed/mod.rs` (feature `composed`) +
> `tests/composed-egress.rs`, with a `wire_tunnel_direct` topology
> added to `tests/harness.rs` (the consumer-side topology with
> alktunnels' direct op on the producer's serving registry — the
> alktunnels' direct op on the producer's serving registry; the
> composed egress's `ChannelClient` is the direct-op caller).
>
> Two deviations from the POC shape, both deliberate:
>
> - **The closed-queue condition is a live-relay count, not a queue
> close.** The egress holds its own factory `Sender`, so the shared
> `mpsc` queue can never fully disconnect — `recv()` would never
> `mpsc` queue can never fully disconnect: `recv()` would never
> return `None` and the POC's closed-queue arm was unreachable
> through this surface. Relays instead emit an `Ended` marker on
> their tunnel's EOF; `next_reply` counts them (`live_relays`) and
@@ -134,7 +134,7 @@ Deliverables:
> the queue is drained. The readiness contract is unchanged (the
> wait is a real async park; a drained-but-live relay set parks).
> - **The tunnel codec is alktunnels' `wire::DatagramReader`
> (`[len]` + payload), not this crate's RFC-framed one** — the POC
> (`[len]` + payload), not this crate's RFC-framed one**; the POC
> fed the tunnel read half into the SOCKS wire reader (a bug class
> the round-trip test caught during bring-up); the composed egress
> frames the tunnel wire once with the tunnel codec and the RFC
@@ -154,7 +154,7 @@ Deliverables:
> The composed egress landed: `composed = ["dep:alktunnels"]`
> (alktunnels pinned "0.2.0"; dep rides `composed` alone),
> `src/composed/mod.rs` — `direct_egress` (the `EgressFactory`), the
> `src/composed/mod.rs` (`direct_egress`, the `EgressFactory`), the
> lazy destination→tunnel table with LRU eviction at cap (`DEFAULT_
> MAX_TUNNELS` = 256), per-tunnel relay tasks draining into a shared
> inbound queue, `SessionOwner` (the owner wrapper shared with the
+26 -25
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-consumer-associate
name: "Core — consumer ASSOCIATE: AssociateSession + e2e tests"
name: "Core: consumer ASSOCIATE, AssociateSession + e2e tests"
status: completed
depends_on: [implementation/core-consumer-connect, implementation/core-producer-associate]
scope: moderate
@@ -20,20 +20,20 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/consumer.rs`,
adapted to the crate's wire codec):
1. **`AssociateSession::associate(client)`**: opens the channel,
negotiates (NoAuth), sends ASSOCIATE, reads the reply —
negotiates (NoAuth), sends ASSOCIATE, reads the reply;
**receives the sentinel BND.ADDR and never uses it for
addressing** (the handle is the channel; ADR-003).
2. **`send_datagram(dst, bytes)`**: frames once
(`[len: u16 BE][addr + data]` via the crate's wire codec — the
(`[len: u16 BE][addr + data]` via the crate's wire codec, the
framing-once mirror) and writes the frame.
3. **`recv_datagram()`** returns `(source_addr, bytes)` per
datagram — **the incremental reader state (mid-frame position +
datagram, and **the incremental reader state (mid-frame position +
batched-datagram pending queue) persists across calls**: a
datagram split across channel chunks survives a caller-driven
receive loop (POC #2's finding — the first cut dropped the reader
receive loop (POC #2's finding: the first cut dropped the reader
state per call; the 2-byte-prefix chunk test exposed it). Write
the persistence test (the POC's chunk-splitting echo shape).
4. **`shutdown()`** half-closes the stream — the mux's implicit-EOF
4. **`shutdown()`** half-closes the stream: the mux's implicit-EOF
sentinel reaches the producer's stage as EOF and the association
ends (association lifetime = channel lifetime).
5. **Teardown ownership**: the session owns the channel; on Drop it
@@ -68,12 +68,12 @@ adapted to the crate's wire codec):
- docs/architecture/decisions/003-udp-handle-swap.md (the consumer
mirror invariants)
- POC source: `/workspace/alksocks-udp-poc/src/consumer.rs`
(`UdpAssociation` — the reader-state finding's origin)
(`UdpAssociation`, the reader-state finding's origin)
## Notes
- **`send_datagram` takes `impl ToTargetAddr`** (the CONNECT
session's target-construction posture — ADR-012: IP-string forms
session's target-construction posture; under ADR-012, IP-string forms
parse, domains pass through unresolved), not the POC's
`&TargetAddr`: the same entry shape as `connect`. A
`ToTargetAddr for &TargetAddr` impl was added to the forked
@@ -81,7 +81,7 @@ adapted to the crate's wire codec):
`TargetAddr` value across sends.
- **Teardown ownership matches the CONNECT/BIND sessions** (the
crate's posture, strengthened from the POC's "close/join"): the
session owns the adopted channel entry — Drop sync-reaps
session owns the adopted channel entry; Drop sync-reaps
(`teardown_channel`, best-effort; `Drop` cannot await) + the data
plane EOFs structurally (the mux's implicit-EOF sentinel);
`shutdown()` half-closes (the graceful path, returning the reap
@@ -92,27 +92,28 @@ adapted to the crate's wire codec):
- **`recv_datagram` returns `Result<Option<(TargetAddr, Bytes)>>`**
(the POC's shape): `Ok(None)` = channel EOF (the association's
end); a stream end mid-frame is fail-loud (an `UnexpectedEof`
I/O error through `SocksSessionError::Rfc` — never a silent
I/O error through `SocksSessionError::Rfc`; never a silent
partial datagram); an undecodable frame (garbage ATYP) errors
loud through the wire codec's `WireError` → RFC mapping
(`AddrError` class for address-encoding refusals,
`UdpHeaderError` for the garbage shapes — the consumer-side
`UdpHeaderError` for the garbage shapes, the consumer-side
mirror of the producer stage's `UndecodableFrame` exit).
- **The pending queue stores decoded datagrams** (`(TargetAddr,
Bytes)`) — the POC stored pre-decode inner bytes; decoded storage
keeps the delivery shape uniform (decode once per frame either
way; no wire.rs change needed — `decode_inner` stays private).
Bytes)`) rather than the POC's pre-decode inner bytes; decoded
storage keeps the delivery shape uniform (decode once per frame
either way; no wire.rs change needed, since `decode_inner` stays
private).
- **Unit tests ride a duplex-backed session** (`skip_auth`'s
handshake-free `use_stream` path over a `BiStream::from_joined`
duplex — no channels needed) for the reader-internals probes:
duplex, no channels needed) for the reader-internals probes:
reader-state persistence (the 2-byte-prefix chunk shape, the POC
finding's trigger), mid-frame EOF fail-loud, garbage-ATYP fail-
loud, and the wire-error mapping table.
- **The e2e split-datagram test uses a 60 KiB payload** (60007
inner bytes — legal under the u16 joint bound, well over the 16
inner bytes, legal under the u16 joint bound, well over the 16
KB read buffer): first → big → third, in order, with the big
datagram reassembling across the call boundary and the third
drained through the pending queue — both halves of the
drained through the pending queue; both halves of the
persistence invariant pinned end to end.
- **No harness changes**: the consumer-side topology's
`wire_consumer_side_with` + the producer-associate task's
@@ -127,20 +128,20 @@ adapted to the crate's wire codec):
Implemented the UDP ASSOCIATE consumer session in `src/consumer.rs`
(POC #2's shape, adapted to the crate's teardown-owning session
posture): `AssociateSession::associate` (open → no-auth → ASSOCIATE
→ reply, capturing the sentinel BND.ADDR as `relay_addr()` —
received, never used for addressing; the ASSOCIATE DST.ADDR is the
→ reply, capturing the sentinel BND.ADDR as `relay_addr()`,
received but never used for addressing; the ASSOCIATE DST.ADDR is the
vanilla-mirror unspecified address), `send_datagram` (frames once
via the crate's wire `frame()` — the framing-once mirror, the joint
via the crate's wire `frame()` (the framing-once mirror, the joint
bound enforced at frame time), `recv_datagram` (the incremental
`DatagramReader` + batched-datagram pending queue, **state that
persists across calls** — the POC's reader-state finding, with the
persists across calls**; that is the POC's reader-state finding, with the
fail-loud shapes: mid-frame EOF and undecodable frames), `shutdown`
(the half-close; the mux's implicit-EOF sentinel reaches the
producer's stage as EOF — association lifetime = channel lifetime),
producer's stage as EOF; association lifetime = channel lifetime),
plus `split`/`into_inner` accessors and the CONNECT sessions'
teardown ownership (Drop sync-reap + structural EOF, `close()`
graceful, teardown-at-birth on in-stream refusals, no `Clone` —
`compile_fail`-asserted). Typed error surface throughout: open
graceful, teardown-at-birth on in-stream refusals, no `Clone`,
since `compile_fail` is asserted there). Typed error surface throughout: open
failures are `SocksSessionError::Open` (ADR-049 reasons); the
missing-egress 0x07 and factory-failure codes surface from
`associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8
@@ -153,6 +154,6 @@ Drop teardown + connection health, the missing-egress refusal
(typed 0x07, no phantom channel), the egress-factory failure
mapping, and the CONNECT+ASSOCIATE regression guard on one
registration (interleaved, both serving). Verification: `cargo
test` 305 (incl. the 3 doctests — the compile-fail assertions ride
test` 305 (incl. the 3 doctests; the compile-fail assertions ride
among them), clippy `-D warnings` (all targets), fmt, `cargo doc
--no-deps`, and both wasm checks clean.
+12 -12
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-consumer-bind
name: "Core — consumer BIND: BindSession (reply#2 reader) + e2e tests"
name: "Core: consumer BIND, BindSession (reply#2 reader) + e2e tests"
status: completed
depends_on: [implementation/core-consumer-connect, implementation/core-producer-bind]
scope: narrow
@@ -21,11 +21,11 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/consumer.rs`,
adapted to the crate's error surface):
1. **`BindSession::bind(client, target)`**: opens the channel,
negotiates, sends the BIND command — the forked client
negotiates, sends the BIND command: the forked client
`request()` reads reply#1 (carrying BND.ADDR).
2. **The reply#2 reader (`await_inbound`)**: reply#2 is hand-read
with the forked public `read_address` codec (reply#1's codec
applied again — **no second codec**, ADR-011's rationale). The
applied again with **no second codec**, ADR-011's rationale). The
session then rides the pass-through data plane
(`BindDataPlane`-shaped: `into_inner()`/`get_socket_mut()`).
3. **Error surface**: a refused BIND (the command gate's 0x02, or a
@@ -71,7 +71,7 @@ adapted to the crate's error surface):
session (the alktunnels ADR-005 posture the CONNECT session
pinned): `BindSession` captures reply#1's BND.ADDR at `bind` (the
forked client `request()` returns it once) and exposes the phase
through borrows — `reply1()` yields the `BindReply1` handle
through borrows: `reply1()` yields the `BindReply1` handle
(`bind_addr` + `raw_mut`), `await_inbound(&mut self)` reads reply#2
and returns the borrowed `BindDataPlane` (`Ok(None)` on a spent
session). The session stays the single teardown owner for every
@@ -81,7 +81,7 @@ adapted to the crate's error surface):
`ReplyError::from_u8` → the forked public `read_address` codec (no
second codec, ADR-011). Errors ride `SocksSessionError` (the
`SocksError` conversions routed through the forked vocabulary, per
the crate's error-surface convention — `AddrError`/`ReplyError` are
the crate's error-surface convention (`AddrError`/`ReplyError` are
wrapped into `SocksError` first because the session error type
exposes `From<SocksError>`, not the RFC inner enums directly).
- `BindDataPlane<'s>` borrows the session's stream (the CONNECT
@@ -91,19 +91,19 @@ adapted to the crate's error surface):
(the POC shape), and `AsyncRead`/`AsyncWrite` are forwarded so the
plane is a plain pipe (`pump_bidi`-shaped consumers work without
the accessors). `BiStream` is not `Sync`, so the plane pins `Send`
(runtime probe) — same shape the forked client type has.
(runtime probe), the same shape the forked client type has.
- Teardown: Drop = sync reap + structural EOF (the CONNECT posture);
`close()` = half-close (the EOF sentinel reaches the producer's
pump) then reap. Teardown-at-birth on the in-stream refusal path
(command-gate 0x02, factory-failure reply#1): the adopted channel
is reaped before the error returns. Post-reply#1 failures
(accept-stage, quiet-wait garbage) keep the channel — reply#1
(accept-stage, quiet-wait garbage) keep the channel: reply#1
succeeded, the listener existed; `close`/`Drop` reaps.
- The quiet-wait garbage path surfaces at `await_inbound` as an
`Io`-shaped error (the producer ends the session with no reply#2;
the consumer's reply#2 read EOFs) — the client-side mirror of the
the consumer's reply#2 read EOFs), the client-side mirror of the
producer's `UnexpectedUdpControlGarbage` exit, pinned by test.
- Harness reuse: no harness changes — the consumer-side topology's
- Harness reuse: no harness changes. The consumer-side topology's
`wire_consumer_side_with` + the producer-bind task's
`echo_accept`/`refusing_accept*` closures cover every test; the
parked-accept factory for the quiet-wait garbage test is
@@ -114,14 +114,14 @@ adapted to the crate's error surface):
Implemented the BIND consumer session in `src/consumer.rs` (POC #5's
shape, adapted to the crate's teardown-owning session posture):
`BindSession::bind` (open → no-auth → BIND → reply#1, capturing
BND.ADDR), `reply1()` (the `BindReply1` handle — the app server's
BND.ADDR), `reply1()` (the `BindReply1` handle: the app server's
out-of-band dial address + `raw_mut`), `await_inbound` (the POC's
`read_reply2` — hand-read with the forked `read_address` codec, no
`read_reply2`, hand-read with the forked `read_address` codec, with no
second codec) returning the `BindDataPlane` (`into_inner`/
`get_socket_mut` + `AsyncRead`/`AsyncWrite` forwards), and the
CONNECT session's teardown ownership (Drop sync-reap + structural
EOF, `close()` graceful, teardown-at-birth on in-stream refusals, no
`Clone` — `compile_fail`-asserted). Typed error surface throughout:
`Clone`, with `compile_fail` asserting it). Typed error surface throughout:
open failures are `SocksSessionError::Open` (ADR-049 reasons); the
command gate's 0x02 and factory-failure reply#1 codes surface from
`bind` as `Rfc`; accept-stage reply#2 codes surface at
+18 -17
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-consumer-connect
name: "Core — consumer half: Socks5ChannelSession (CONNECT) + e2e tests"
name: "Core: consumer half, Socks5ChannelSession (CONNECT) + e2e tests"
status: completed
depends_on: [implementation/core-producer-connect]
scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description
Implement the CONNECT consumer session in `src/consumer.rs` per
consumer.md §The Channels-Native Sessions: `Socks5ChannelSession` —
consumer.md §The Channels-Native Sessions: `Socks5ChannelSession`,
the typed client that opens `channels/socks5/sub` and drives the
client side of the RFC conversation in-stream (POC #1's
`use_stream` shape).
@@ -22,23 +22,24 @@ consumer.rs`, adapted to the crate's params/error surface):
1. **`Socks5ChannelSession::connect(client, target)`**: opens the
channel via `ChannelClient::open_channel(OP_SOCKS5_OPEN, params,
SOCKS5_ALPN)` (trivial params — ADR-001), then drives the forked
SOCKS5_ALPN)` (trivial params, ADR-001), then drives the forked
client typestate's `use_stream` path over the channel's
`BiStream` (generic over the backing stream — the POC #1 fit:
`BiStream` (generic over the backing stream, and the POC #1 fit:
`Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so the
session over a `BiStream` is the intended use).
2. **Target construction** via `ToTargetAddr` (IP-string forms parse
to IP targets; domain form passes through unresolved — ADR-012).
to IP targets; domain form passes through unresolved, per
ADR-012).
3. **Post-reply payload pipe**: after the reply, the session is a
raw byte pipe — expose `split()` (read/write halves) and
raw byte pipe. Expose `split()` (read/write halves) and
`into_inner()` (the underlying `Socks5Stream<BiStream>` for
advanced use). 0 B framing (CONNECT).
4. **Teardown ownership** (alktunnels ADR-005 posture, inherited):
the session owns the channel; on Drop it closes/joins. No
`Clone` (compile-asserted — aliasing a session would alias its
`Clone` (compile-asserted, since aliasing a session would alias its
teardown; the alktunnels `compile_fail` doctest shape).
5. **Error surface**: open failures are the typed
`SocksOpenError` reasons (ADR-049 §4 — a refused session never
`SocksOpenError` reasons (ADR-049 §4: a refused session never
yields a phantom channel); a refused target is an RFC reply code
surfaced through the session type (consumer.md §Error surface).
6. **Tests** (end-to-end through the harness): connect → echo
@@ -63,7 +64,7 @@ consumer.rs`, adapted to the crate's params/error surface):
- docs/architecture/consumer.md §The Channels-Native Sessions
- docs/architecture/decisions/003-udp-handle-swap.md (the framing
mirror — CONNECT is the 0 B case)
mirror, where CONNECT is the 0 B case)
- POC source: `/workspace/alksocks-connect-poc/src/consumer.rs`
- alktunnels template: `/workspace/@alkdev/alktunnels/src/consumer.rs`
(the teardown-ownership + no-Clone posture)
@@ -77,23 +78,23 @@ consumer.rs`, adapted to the crate's params/error surface):
over `BiStream::from_joined(recv, send)`, then `request(TCPConnect,
target)`. `Config::default()` is the no-auth posture (the
`skip_auth` knob stays internal to the fork; the identity seam is
the open op's, CF-005/CF-006 — no RFC-facing auth on this path).
the open op's, CF-005/CF-006; no RFC-facing auth on this path).
- `split()` returns borrowed halves (`tokio::io::split` over a
`&mut Socks5Stream<BiStream>`; the `SplitPipe` type alias), not
owned ones: the session keeps teardown ownership for every shape,
including the split-driven one. The owned path is `into_inner()`
(`TakenInner { session, inner }` — the session rides along as the
(`TakenInner { session, inner }`; the session rides along as the
teardown owner).
- Refusal teardown: an in-stream refusal (Rfc error) tears the
adopted channel down inside `connect` before the error returns
(teardown-at-birth; the phantom-channel property, tested both
sides). The producer's own handler-exit teardown races the
consumer's — the leak-check probe polls past that race.
consumer's, and the leak-check probe polls past that race.
- Drop is the sync-reap posture (`teardown_channel` is sync;
`Drop` cannot await) + the structural EOF of the dropped pipe; the
graceful path is `close()` (write-half shutdown first, then reap).
- Harness: added the consumer-side topology (`wire_consumer_side[_with]`
— the consumer dials with a real `ChannelClient`; the producer
- Harness: added the consumer-side topology (`wire_consumer_side[_with]`,
where the consumer dials with a real `ChannelClient` and the producer
accepts with the real registration) plus `witnessing_echo_dial`
(the dial-witness target-form probe). The `allow(dead_code)` attr
rides the alktunnels harness posture (each test binary compiles the
@@ -103,15 +104,15 @@ consumer.rs`, adapted to the crate's params/error surface):
## Summary
Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs` —
Implemented `Socks5ChannelSession` (CONNECT) in `src/consumer.rs`:
`connect`/`channel_id`/`split`/`into_inner`/`close` + `Drop` teardown
ownership and the `SplitPipe`/`TakenInner` companions — with the
ownership and the `SplitPipe`/`TakenInner` companions, with the
typed error surface (`SocksOpenError` for opens, the RFC reply code
through `SocksSessionError::Rfc` for refused targets) and
teardown-at-birth on the in-stream refusal path. E2E suite
(`tests/consumer-connect.rs`, 9 tests over the new consumer-side
harness topology): echo round-trip + CF-006 witness, target forms
(IP-string parses / domain passes through — ADR-012, wire-observed
(IP-string parses / domain passes through, per ADR-012 and wire-observed
at the dial), refused dial → typed RFC error + no phantom channel,
FORBIDDEN open → typed open error + no session, drop teardown +
connection health, half-close EOF semantics, `into_inner` pairing,
+22 -22
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-crate-deps
name: Core — crate dependencies + module skeleton (alkcall wiring)
name: "Core: crate dependencies + module skeleton (alkcall wiring)"
status: completed
depends_on: []
scope: narrow
@@ -14,35 +14,35 @@ level: implementation
Add the dependency surface the wrapper layers need and the module
skeleton so the batch-1 core tasks have their targets. This is the
batch-1 analogue of the vendor scaffolding task: the crate gains
`alkcall` (its substrate — first consumption of the 0.8.0 surface) and
`alkcall` (its substrate, and first consumption of the 0.8.0 surface) and
the module files named in overview.md §Module Map, born wasm-clean.
Deliverables:
1. **`Cargo.toml` `[dependencies]`** — add to the existing trio
1. **`Cargo.toml` `[dependencies]`**: add to the existing trio
(tokio wasm-clean subset, tracing, thiserror):
- `alkcall = "0.8.0"` (pinned per AGENTS.md convention 11 — bump
- `alkcall = "0.8.0"` (pinned per AGENTS.md convention 11; bump
deliberately, never transitively)
- `serde = { version = "1", features = ["derive"] }`,
`serde_json = "1"` (the params schema + spec builder)
- `bytes = "1"` (the wire codec's buffer types)
- `futures = { version = "0.3", default-features = false,
features = ["std"] }` (BoxFuture for the seam callbacks — the
features = ["std"] }` (BoxFuture for the seam callbacks, the
alktunnels precedent; verify the wasm-clean subset compiles)
- tokio stays `default-features = false` with the wasm-clean
subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never
subset (`rt`, `sync`, `io-util`, `macros`, `time`); **never
`features = ["full"]`** (AGENTS.md convention 4). No
`tokio/net`, no `socket2` — those ride `local` in batch 2.
`tokio/net`, no `socket2`: those ride `local` in batch 2.
- `[features]` stays `default = []`, `local = []`,
`composed = []` — declared empty by design (ADR-009); populated
`composed = []`, declared empty by design (ADR-009); populated
by their modules' batch-2 tasks.
2. **`src/lib.rs`** — keep `#![forbid(unsafe_code)]` and the crate
2. **`src/lib.rs`**: keep `#![forbid(unsafe_code)]` and the crate
docs; declare the module skeleton per overview.md §Module Map:
`params`, `wire`, `producer`, `consumer`, `egress`, `error` —
`params`, `wire`, `producer`, `consumer`, `egress`, `error`;
one-line stub files, each with a module doc naming its filling
task (the vendor-scaffolding precedent). `rfc1928` stays as is.
3. **Re-export posture**: `lib.rs` re-exports public API per
convention 15 — for now the module declarations only; each
convention 15; for now the module declarations only, and each
implementation task adds its own re-exports.
## Acceptance Criteria
@@ -50,7 +50,7 @@ Deliverables:
- [x] `cargo test` passes
- [x] `cargo clippy --all-targets -- -D warnings` clean
- [x] `cargo fmt --check` clean
- [x] `cargo check --target wasm32-unknown-unknown` clean — the wasm
- [x] `cargo check --target wasm32-unknown-unknown` clean; the wasm
gate stays green with alkcall + serde + bytes + futures in the
graph (alkcall 0.8.0's default crate is wasm-clean; verify, do
not assume)
@@ -80,28 +80,28 @@ Deliverables:
self-contained on `wasm32-unknown-unknown`. The `futures = {
default-features = false, features = ["std"] }` subset compiles in
the same graph (probe build in `/tmp/opencode/wasm-probe`).
- **Cargo.toml** — added `alkcall = "0.8.0"` (pinned, not a range —
- **Cargo.toml**: added `alkcall = "0.8.0"` (pinned, not a range, per
convention 11), `serde = { version = "1", features = ["derive"] }`,
`serde_json = "1"`, `bytes = "1"`, and
`futures = { version = "0.3", default-features = false, features =
["std"] }` (the BoxFuture seam shape; the std-only subset, not
default features — the latter is the same set in practice, but the
default features. The latter is the same set in practice, but the
explicit subset is the documented posture). tokio unchanged (the
wasm-clean subset, `default-features = false` — never `full`); the
wasm-clean subset, `default-features = false`; never `full`); the
full-tokio dev-dep stays native-only. `[features]` untouched
(`default = []`, `local = []`, `composed = []` — ADR-009, populated
(`default = []`, `local = []`, `composed = []`; ADR-009, populated
by batch-2).
- **lib.rs** — kept `#![forbid(unsafe_code)]` + crate docs; declared
- **lib.rs**: kept `#![forbid(unsafe_code)]` + crate docs; declared
the six skeleton modules per overview.md §Module Map: `params`,
`wire`, `producer`, `consumer`, `egress`, `error` (alphabetical
declaration order after `rfc1928`; rustfmt-sorted). No re-exports
yet (convention 15 posture: each implementation task adds its own).
- **Stub files** — one-line module docs (2–5 lines) each naming its
- **Stub files**: one-line module docs (2–5 lines) each naming its
filling task: `params`/`error` → core-params-errors, `wire` →
core-wire-codec, `egress` → core-seam-types, `producer` →
core-producer-connect (+ associates/bind), `consumer` →
core-consumer-connect (+ associates/bind). `door.rs`, `local/`,
`composed/` are deliberately absent — feature-gated batch-2 modules
`composed/` are deliberately absent, being feature-gated batch-2 modules
born with their features, per the overview module map's local/
composed posture.
- Dep-graph spot checks: `cargo tree --target
@@ -109,7 +109,7 @@ Deliverables:
nothing (not in the wasm normal graph); the normal-only tokio
feature set on both native and wasm targets is exactly
`bytes/default io-util macros rt sync time` (the `net`/`full`
features appear only via the dev-dependency tokio — the test-only
features appear only via the dev-dependency tokio, which is the test-only
surface, by design).
## Summary
@@ -117,7 +117,7 @@ Deliverables:
Added the batch-1 dependency surface and the module skeleton:
`Cargo.toml` gains `alkcall = "0.8.0"` (the substrate pin,
convention 11), `serde`/`serde_json`, `bytes`, and the std-only
`futures` subset — tokio's wasm-clean subset and the empty
`futures` subset, with tokio's wasm-clean subset and the empty
`local`/`composed` features unchanged; `src/lib.rs` declares the six
skeleton modules (`params`, `wire`, `producer`, `consumer`, `egress`,
`error`) per overview.md §Module Map, each a stub with a module doc
@@ -125,7 +125,7 @@ naming its filling task (the vendor-scaffolding precedent); no
re-exports yet. Wasm-cleanliness verified before and after: alkcall
0.8.0's crates.io artifact compiles clean on
`wasm32-unknown-unknown` with no patching (probe build), and the
crate's checks all pass — `cargo test` (39+37 tests, 2 doc-ignored),
crate's checks all pass: `cargo test` (39+37 tests, 2 doc-ignored),
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`cargo doc --no-deps`, both wasm checks, and the `mio`/`socket2`/
`tokio::net` absence spot-checks on the default (normal) dependency
+19 -19
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-integration-suite
name: Core — cross-cutting integration suite + docs sync
name: "Core: cross-cutting integration suite + docs sync"
status: completed
depends_on: [implementation/core-consumer-associate, implementation/core-consumer-bind]
scope: moderate
@@ -25,7 +25,7 @@ Deliverables:
served on the same `channels/socks5/sub` op, interleaved
sessions on one connection.
- **Concurrency**: multiple concurrent sessions of mixed command
types over one channels connection (the mux/demux under load —
types over one channels connection (the mux/demux under load;
the POCs tested sequentially; this pins concurrency).
- **Teardown semantics matrix**: client half-close ends a CONNECT
data plane cleanly (the producer's pump exits, the connection
@@ -36,7 +36,7 @@ Deliverables:
- **Identity/scope matrix**: `socks5:open`-only (CONNECT +
ASSOCIATE yes, BIND 0x02), `socks5:bind`-only (BIND yes,
CONNECT/ASSOCIATE FORBIDDEN at the open gate), both-scopes
(everything) — the two-grants-never-implied posture (ADR-006)
(everything); the two-grants-never-implied posture (ADR-006)
pinned end to end.
- **The readiness contract** re-pinned at the integration level
(the producer-associate task owns the unit-level test; this
@@ -47,12 +47,12 @@ Deliverables:
follow the decomposition phase" line updated).
- Any spec drift the implementation surfaced (naming, shapes,
the wire.md/bast.md residue already fixed in the wire-codec
task) reconciled in the same change — never silently diverged.
task) reconciled in the same change, never silently diverged.
- `lib.rs` crate docs updated to describe the landed surface
(producer + consumer + wire + seams), pointing at the specs.
3. **Full verification sweep** (the batch gate's input):
`cargo test`, `cargo test --all-features` (features are still
empty — verify the flag combination compiles), clippy
empty, so verify the flag combination compiles), clippy
`--all-targets -- -D warnings`, `cargo fmt --check`, both wasm
checks, and `cargo doc --no-deps` (docs changed).
@@ -64,7 +64,7 @@ Deliverables:
CONNECT/ASSOCIATE FORBIDDEN at the open gate"). The landed ADR-006
+ implementation pin the open op's `required_scopes =
["socks5:open"]` **unconditionally** (the single op, all three
commands; ADR-001) — a bind-only identity fails closed at the open
commands; ADR-001); a bind-only identity fails closed at the open
gate for every command (FORBIDDEN, the establisher never runs, no
phantom channel), and a BIND-capable deployment grants both scopes
explicitly. The suite pins the landed posture
@@ -72,28 +72,28 @@ Deliverables:
producer- and consumer-side; ADR-006 §Consequences now states the
asymmetry explicitly (never implied, in either direction). The
task's phrasing was a Phase-0-era guess at the gate shape, not a
spec row the code contradicted — the spec (ADR-006 + producer.md)
spec row the code contradicted: the spec (ADR-006 + producer.md)
and the implementation already agreed.
- **Test-shape notes:** the suite rides the established
`tests/harness.rs` topologies (the raw producer-side topology —
the producer's serving side allocates channel ids — and the
consumer-side topology — the consumer allocates; the mux/demux is
`tests/harness.rs` topologies (the raw producer-side topology,
where the producer's serving side allocates channel ids; and the
consumer-side topology, where the consumer allocates; the mux/demux is
pinned in both allocation directions). The mixed-command
concurrency tests box the per-command round-trip futures into one
`Vec<RoundTrip>` (`Pin<Box<dyn Future>>` — the three async fns
`Vec<RoundTrip>` (`Pin<Box<dyn Future>>`; the three async fns
have distinct opaque types, so a shared boxed type is the
`join_all` vehicle). The BIND client-gone row uses a parked accept
factory so the quiet-wait exit is the only possible end (the
deterministic client-gone probe).
- **Readiness contract under load:** the integration-level pin
reuses the producer-associate unit test's hot-`Ok(None)` egress
shape across concurrent associations (with a CONNECT interleaved)
— the stage's re-park tolerance must hold concurrently, not just
shape across concurrent associations (with a CONNECT interleaved),
so the stage's re-park tolerance must hold concurrently, not just
sequentially.
## Summary
Landed the batch-1 closing task: `tests/core-integration.rs` — nine
Landed the batch-1 closing task: `tests/core-integration.rs`, nine
cross-cutting integration tests pinning the whole protocol core end
to end over the channels harness (the alkcall 0.8.0 establishment
surface, both topology directions):
@@ -105,7 +105,7 @@ surface, both topology directions):
ends only its own session (mux/demux isolation).
2. **Concurrency**: nine concurrent mixed-command sessions over one
channels connection (the raw topology), six through the typed
consumer sessions (the consumer-side topology — the other
consumer sessions (the consumer-side topology, the other
allocation direction), then a fresh round-trip of each command
after the burst; no channel entry leaks on either side.
3. **The teardown matrix** (wire.md §Sentinels and lifecycle): a
@@ -126,13 +126,13 @@ surface, both topology directions):
branch.
Docs sync (the architecture-sync check): `docs/architecture/README.md`
Current State rewritten — "the protocol core is implemented," the
Current State rewritten: "the protocol core is implemented," the
landed per-module inventory, `tests/core-integration.rs` named as the
batch-1 pin, batch 2 (`local`/`composed`) as the follow; the
"remaining layers follow the decomposition phase" residue gone.
`consumer.md` §Wiring reconciled to the landed signatures
(`&ChannelClient` constructors, `impl ToTargetAddr` datagrams, the
`Option`-shaped `recv_datagram` — `None` is the channel-EOF
`Option`-shaped `recv_datagram` (`None` is the channel-EOF
association end, never an empty datagram). ADR-006 §Consequences now
states the grant asymmetry explicitly. `src/lib.rs` crate docs
rewritten to describe the landed surface (rfc1928 fork, wire codec,
@@ -161,8 +161,8 @@ features `local`/`composed` compile), `cargo clippy --all-targets --
## Notes
> Filled by the implementation agent — see the Notes above.
> Filled by the implementation agent: see the Notes above.
## Summary
> Filled on completion — see the Summary above.
> Filled on completion: see the Summary above.
+18 -18
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-params-errors
name: Core — params schema + scope constants + typed error surface
name: "Core: params schema + scope constants + typed error surface"
status: completed
depends_on: [implementation/core-crate-deps]
scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description
Implement `src/params.rs` and `src/error.rs` per wire.md §The Open
Op, ADR-001, and ADR-006 — the wire-stable surface (the one-way door)
Op, ADR-001, and ADR-006, i.e. the wire-stable surface (the one-way door)
and the typed open-error vocabulary. Template:
`/workspace/@alkdev/alktunnels/src/params.rs` (the sibling's spec
builder + scope-constant posture).
@@ -25,19 +25,19 @@ Deliverables:
"socks5:open"`, `SOCKS5_BIND_SCOPE = "socks5:bind"` (ADR-001/
ADR-006; the scope-string convention `<alpn-family>:
<capability>`; stable once published).
- `socks5_open_spec() -> OperationSpec` — `Sub`-typed, external,
- `socks5_open_spec() -> OperationSpec`: `Sub`-typed, external,
channel-open marker on `SOCKS5_ALPN`, **trivial params**:
input schema `{type: object}`, `additionalProperties: false`
(empty params stay valid forever — ADR-001's additive-only
(empty params stay valid forever, by ADR-001's additive-only
extension rule); reply schema `{"channel_id": integer,
minimum 1}`; `required_scopes = ["socks5:open"]`. Follow the
alktunnels spec-builder shape exactly (the registry validates
against these schemas).
- The trivial params struct (`Socks5Params {}` — serde
- The trivial params struct (`Socks5Params {}`, with serde
`deny_unknown_fields`, empty) for the consumer's open call.
2. **`src/error.rs`** — the typed open-error surface (alkcall
2. **`src/error.rs`**: the typed open-error surface (alkcall
ADR-049 §4):
- `SocksOpenError` — the consumer-side open-failure vocabulary
- `SocksOpenError`: the consumer-side open-failure vocabulary
mapping the `channel:open_failed` reasons (`dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
`timeout`) plus the registry's `FORBIDDEN`/`INVALID_INPUT`
@@ -45,15 +45,15 @@ Deliverables:
`establishment_reason` helper.
- The session-level error type for the consumer sessions
(`SocksSessionError`-shaped: open errors vs in-stream
`SocksError` vs I/O) — the exact variant set is the task's
`SocksError` vs I/O). The exact variant set is the task's
judgment; keep it faithful to what the sessions actually
surface (a refused session never yields a phantom channel; a
refused target is an RFC reply code surfaced through the
session type — consumer.md §Error surface).
session type, per consumer.md §Error surface).
3. **lib.rs re-exports** for the new public surface (convention 15).
Tests: spec-builder shape assertions (op id, ALPN marker, scopes,
schemas — the trivial-params schema rejects unknown fields), params
schemas; the trivial-params schema rejects unknown fields), params
serde round-trip (empty object valid; unknown field rejected), the
error-reason mapping table.
@@ -91,16 +91,16 @@ error-reason mapping table.
(Sub/External/channel-open marker on `alk/socks5`; input schema
`{type: object, additionalProperties: false}`; reply schema
`channel_id` integer minimum 1 required; `required_scopes =
["socks5:open"]`), and the `establishment_reason` helper re-export
(the alktunnels posture — delegates to
`["socks5:open"]`), and the `establishment_reason` helper re-export
(the alktunnels posture, which delegates to
`ChannelOpenError::establishment_reason`).
- `src/error.rs`: `SocksOpenError` (single `Open` variant wrapping
`ChannelOpenError` — the same shape as alktunnels'
`ChannelOpenError`, the same shape as alktunnels'
`TunnelOpenError`; the `From` impl covers the
`channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the
typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five
codes, `parse`/`as_str`; unknown codes are `None` — never silently
aliased, the vocabulary is one-way), `SocksOpenError::reason()` as
codes, `parse`/`as_str`; unknown codes are `None`, never silently
aliased, since the vocabulary is one-way), `SocksOpenError::reason()` as
the typed view, and `SocksSessionError` (open errors vs in-stream
`Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the
session type per consumer.md §Error surface). `SocksError`/
@@ -110,12 +110,12 @@ error-reason mapping table.
- lib.rs re-exports both modules' public surface (convention 15).
- Deliberate deviations from the task text: the params struct
derives `Serialize` (test round-trip needs it; harmless on the
wire — the consumer sends `json!({})`); `SocksEstablishmentReason`
wire, since the consumer sends `json!({})`); `SocksEstablishmentReason`
is a separate named type rather than an inline match so the
mapping table is testable in both directions; unknown reason codes
do NOT alias a variant (one-way vocabulary discipline).
- rustdoc links to `error::SocksOpenError` from `params` are
fully-qualified (`crate::error::…`) — plain `[SocksOpenError]`
fully-qualified (`crate::error::…`); plain `[SocksOpenError]`
does not resolve cross-module in this crate's layout.
## Summary
@@ -130,7 +130,7 @@ open-error vocabulary:
`socks5:open`/`socks5:bind`, trivial empty-object input schema with
loud `additionalProperties: false`, reply
`{channel_id: integer, minimum 1}` required).
- `Socks5Params {}` with `deny_unknown_fields` — empty object valid,
- `Socks5Params {}` with `deny_unknown_fields`: empty object valid,
unknown fields rejected.
- `error`: `SocksOpenError` mapping the ADR-049 §4 reasons
(`dial_failed`/`unknown_resource`/`resource_shortage`/
+20 -19
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-producer-associate
name: "Core — producer ASSOCIATE: the select!-driven datagram stage"
name: "Core: producer ASSOCIATE, the select!-driven datagram stage"
status: completed
depends_on: [implementation/core-wire-codec, implementation/core-producer-connect]
scope: moderate
@@ -18,17 +18,17 @@ refusal with the datagram stage (POC #2's validated scope).
Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
+ `egress.rs`, adapted to the crate's wire codec and seam types):
1. **The ASSOCIATE arm**: `reply_success(sentinel)` — the sentinel
1. **The ASSOCIATE arm**: `reply_success(sentinel)`, the sentinel
`127.0.0.1:0` BND.ADDR (pure RFC-compatibility surface; the
channel is the relay handle, ADR-003) — then the datagram stage.
2. **The datagram stage** — `select!`-driven (channel reads ∥ egress
channel is the relay handle, ADR-003), then the datagram stage.
2. **The datagram stage**, `select!`-driven (channel reads ∥ egress
replies), with the POC's three load-bearing findings as
invariants:
- **The concurrent select shape** (not poll-then-block — the
- **The concurrent select shape** (not poll-then-block, since the
poll-then-block window loses replies; the concurrent shape is
also the honest full-duplex shape).
- **The egress readiness contract**: `next_reply` is `Pending`
when nothing is ready — an instantly-ready `Ok(None)` starves a
when nothing is ready; an instantly-ready `Ok(None)` starves a
biased select into livelock. **Write the contract test**: an
egress returning instant `Ok(None)` must not starve the stage's
channel branch (the POC's load-bearing API finding, re-pinned
@@ -45,17 +45,17 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
(`udp_undecodable_frame_ends_association`).
3. **The `EgressFactory` lifecycle**: called once per ASSOCIATE; the
stage owns the egress; stage exit drops it. Ownership rides the
handle structurally — no `_` bindings of owning values (the POC's
handle structurally: no `_` bindings of owning values (the POC's
teardown-at-birth witness).
4. **Missing-egress refusal**: a registration without an
`EgressFactory` refuses ASSOCIATE RFC-faithfully
(`CommandNotSupported`-class) — never a panic (ADR-008).
(`CommandNotSupported`-class), never a panic (ADR-008).
5. **Tests** (via the harness + raw RFC client): associate →
sentinel reply → datagram round-trip through the echo egress
(in-order, multiple datagrams, one association); empty-payload
datagram legal (not EOF); undecodable frame ends the association
loud; channel EOF ends the association and the connection stays
healthy (a fresh associate succeeds immediately after — the POC's
healthy (a fresh associate succeeds immediately after, the POC's
test 4); the readiness-contract test; missing-egress refusal;
CONNECT still served on the same registration (the regression
guard).
@@ -86,14 +86,15 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
## Notes
- **Wire codec addition** (`src/wire.rs`): `frame_inner()` — the
- **Wire codec addition** (`src/wire.rs`): `frame_inner()`, the
reply-direction half of the framing-once invariant (frames
reply-direction half of the framing-once invariant (frames
pre-encoded inner bytes from the egress; the forward-path
`frame()` and the reply-path `frame_inner()` produce identical
wires — pinned byte-for-byte). Exported from lib.rs. `DatagramReader`
wires, pinned byte-for-byte). Exported from lib.rs. `DatagramReader`
and `frame()` were already in place from the wire-codec task.
- **Stage shape** (`src/producer.rs`): `serve_udp_associate` drives
the typestate directly (POC #2's shape — no `run_udp_proxy_custom`),
the typestate directly (POC #2's shape, no `run_udp_proxy_custom`),
then `run_datagram_stage` is a `select!` loop over channel reads ∥
`egress.next_reply()`, returning a typed `StageExit` diagnostic
(ChannelEof / UndecodableFrame / EgressEnded / ReplyFrameRejected /
@@ -104,10 +105,10 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
lifetime); undecodable frames (garbage ATYP, `len == 0`) and
egress `Err` end the association loud; a reply the joint bound
rejects (`ReplyFrameRejected`) is its own exit (never a wire
overflow). The stage owns the egress structurally — the egress
overflow). The stage owns the egress structurally: the egress
binding is moved into the stage call; every exit drops it
(drop-witness test pins the teardown-at-birth discipline).
- **Missing-egress refusal**: `CommandNotSupported` (0x07) — the
- **Missing-egress refusal**: `CommandNotSupported` (0x07), the
ADR-008 missing-callback class, same shape as CONNECT's
missing-dial refusal. Egress-factory failure (post-command,
pre-reply) → reply#1 with the mapped RFC code
@@ -121,7 +122,7 @@ Deliverables (port from `/workspace/alksocks-udp-poc/src/producer.rs`
- The consumer side (the `AssociateSession` mirror) is the
consumer-associate task; its `send_datagram` will frame via
`frame()` and its `recv_datagram` will decode via the persistent
`DatagramReader` — both already exported.
`DatagramReader`; both are already exported.
## Summary
@@ -131,22 +132,22 @@ stage: sentinel reply (`127.0.0.1:0`, ADR-003's RFC-compatibility
surface) → the `select!`-driven stage (channel reads ∥ egress
replies) with the three POC invariants as code:
1. **Concurrent select shape** — both branches of the loop are
1. **Concurrent select shape**: both branches of the loop are
awaited concurrently; no poll-then-block window (the reply-loss
hazard the POC measured).
2. **The egress readiness contract** — spurious `Ok(None)`s are
2. **The egress readiness contract**: spurious `Ok(None)`s are
tolerated by re-parking; the contract test (`udp_instant_ok_none_egress_does_not_starve_the_stage`)
pins that an instantly-ready `Ok(None)` egress must not starve
the channel branch (the POC's load-bearing API finding,
re-pinned by this suite).
3. **Framing applied exactly once, by the wire layer** — the stage
3. **Framing applied exactly once, by the wire layer**: the stage
reads frames via `DatagramReader` (egress `send` gets the bare
payload) and writes frames via the new `frame_inner` (egress
`next_reply`'s inner bytes → the channel); the egress never sees
a length prefix.
Association lifetime = channel lifetime (EOF is the clean exit;
tested in-process and over channels — a fresh associate succeeds
tested in-process and over channels; a fresh associate succeeds
immediately after, the POC's test 4). Missing egress refuses
RFC-faithfully (0x07); egress-factory failure maps to reply#1's
code. CONNECT still serves on the same registration (regression
+25 -25
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-producer-bind
name: "Core — producer BIND: the phase model + the socks5:bind command gate"
name: "Core: producer BIND, the phase model + the socks5:bind command gate"
status: completed
depends_on: [implementation/core-producer-connect]
scope: moderate
@@ -21,27 +21,27 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
1. **The command gate**: at BIND dispatch, require `socks5:bind`
from the per-call opener identity (the open-op `AuthContext`'s
identity — CF-005/CF-006). An identity without it gets the
identity of the open-op `AuthContext`, CF-005/CF-006). An identity without it gets the
RFC-faithful `ConnectionNotAllowed` (0x02) refusal while CONNECT
still serves the same identity (POC #5 test 4). The gate is the
open-op identity seam consumed at command-dispatch time — no new
open-op identity seam consumed at command-dispatch time: no new
ops, no params change.
2. **The BIND phase model**:
- **reply#1 carries BND.ADDR** — the `BindAcceptFactory` is
- **reply#1 carries BND.ADDR**: the `BindAcceptFactory` is
called first; its `BindAccept.bind_addr` is reply#1's carrier
(the local variant's real listener; the sentinel on the
wrapper-aware in-band path when no factory is wired — ADR-007
wrapper-aware in-band path when no factory is wired, per ADR-007
variant 3).
- **The quiet wait**: `select!`-single-shot with exactly two
exits — the one inbound accept (the `BindAccept` future), or
exits, the one inbound accept (the `BindAccept` future) or
the control stream's EOF/garbage/error (the client-gone
teardown riding the accept future's drop). **Not a loop** (the
POC's `never_loop` finding — exactly two exits). Any byte in
teardown riding the accept future's drop). **Not a loop** (the
POC's `never_loop` finding: exactly two exits). Any byte in
the quiet wait is protocol garbage → the session ends with no
data plane.
- **reply#2 carries the peer address** (the accepted handle's
peer; sentinel `0.0.0.0:0` wrapper-aware).
- **Phase 2 is raw pass-through** (`pump_bidi` inline — the
- **Phase 2 is raw pass-through** (`pump_bidi` inline, the
CONNECT shape) between the channel and the accepted halves.
3. **Failure mapping is RFC-faithful end to end**: factory failure
(pre-reply#1) → reply#1 with the mapped code; accept-stage
@@ -49,22 +49,22 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
plane. No phantom listeners, no phantom data planes.
4. **The establishment bound covers only the open-op round trip**
(ADR-007): the accept wait lives in the teardown-governed pump
phase — the quiet wait must not run under the establisher's
phase: the quiet wait must not run under the establisher's
10s bound (FTP-length BIND waits stop resolving as spurious
establishment refusals).
5. **Missing-accept refusal**: a registration without a
`BindAcceptFactory` serves the wrapper-aware in-band default
(sentinel reply#1 + sentinel reply#2 — ADR-007 variant 3) or
refuses — the ADR names variant 3 as the in-band default when
(sentinel reply#1 + sentinel reply#2, ADR-007 variant 3) or
refuses; the ADR names variant 3 as the in-band default when
neither provider is wired; implement variant 3 as the
no-factory default and document the choice.
6. **Tests** (via the harness + raw RFC client): BIND → reply#1 →
(the harness accept resolves) → reply#2 with the peer →
pass-through echo both directions; the command gate (a
`socks5:open`-only identity gets 0x02 on BIND while CONNECT
still works — the POC's test 4); quiet-wait garbage byte ends
still works, the POC's test 4); quiet-wait garbage byte ends
the session with no data plane; client-gone teardown (EOF
mid-wait drops the accept future — a fresh dial to the
mid-wait drops the accept future, so a fresh dial to the
advertised address fails); factory failure → reply#1 mapped
code; accept-stage failure → reply#2 mapped code, no data plane;
CONNECT + ASSOCIATE regression guard (one registration, all
@@ -97,32 +97,32 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Notes
- **The gate seam**: `serve_socks5_over<T>` gained a `can_bind: bool`
parameter — the pump handler resolves it from the open-op
parameter: the pump handler resolves it from the open-op
`AuthContext`'s per-call opener identity (CF-005/CF-006: the
`socks5:bind` scope, never implied by `socks5:open`); the
substrate-free entry takes the resolved boolean explicitly, so the
gate is caller-policy at the raw-entry shape and
identity-resolved at the channels shape. A scope-less BIND gets
`ConnectionNotAllowed` (0x02) with no factory call (the gate
precedes the seam — no phantom listener), while CONNECT still
precedes the seam, so no phantom listener), while CONNECT still
serves the same identity (tested unit + over-channels).
- **Variant 3 as the no-factory default** (documented in
`serve_tcp_bind`'s docs): when no `BindAcceptFactory` is wired, the
BIND arm synthesizes a `BindAccept` — the sentinel reply#1 (via
BIND arm synthesizes a `BindAccept`: the sentinel reply#1 (via
`sentinel_bind_peer_addr()` = `0.0.0.0:0` riding the accept
future's instant resolution, so the full phase model is preserved:
reply#1 → quiet wait → reply#2 → pass-through) + the sentinel
reply#2 peer. The channel is the connection (the handle-swap
reading); the in-band halves are `tokio::io::empty`/`sink` — the
reading); the in-band halves are `tokio::io::empty`/`sink`, and the
data plane exists and EOFs.
- **The quiet wait** is a single-shot `select!` with exactly two
exits (`bind_quiet_wait_and_serve`): the accept branch (its drop IS
the client-gone teardown — verified by an owner-guard test), or the
the client-gone teardown, verified by an owner-guard test) or the
control stream's read (EOF = clean end, any byte = protocol
garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data
plane). Not a loop (the POC's `never_loop` finding).
- **Reply#2** uses the fork's published `new_reply` (carried change
#2's payoff — no local re-encode); the peer address carries as
#2's payoff: no local re-encode); the peer address carries as
`TargetAddr::Ip` → the real socket addr, domain-form peer → v4
unspecified with the port.
- **Failure mapping**: factory failure → reply#1's mapped code, then
@@ -130,7 +130,7 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
CONNECT's dial shape); accept-stage failure → reply#2's mapped code,
then `Bug("bind accept refused in-stream")`. No phantom listeners, no
phantom data planes.
- **Establishment bound**: structurally satisfied — the quiet wait
- **Establishment bound**: structurally satisfied. The quiet wait
runs inside the pump handler's spawned task, which the alkcall open
wrapper never bounds (the establisher's 10s bound is pre-handler);
the harness round-trip + the substrate-free "parked accept still
@@ -138,9 +138,9 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
- **Test determinism**: the quiet-wait tests use a parked accept
factory (`std::future::pending`) so the garbage/EOF branch wins
deterministically (an instantly-resolving accept races the garbage
branch — the client-gone test's owner flag witnesses the future's
branch, and the client-gone test's owner flag witnesses the future's
drop).
- **Harness additions**: `bind_scoped_identity()` (both scopes — a
- **Harness additions**: `bind_scoped_identity()` (both scopes, since a
session needs the open gate AND the command gate) + BIND
integration tests: round-trip over channels, the gate over
channels, both-scopes identity serves, quiet-wait garbage over
@@ -156,9 +156,9 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Summary
Implemented the TCP BIND producer path (`src/producer.rs`): the
`socks5:bind` command gate (ADR-006 — resolved from the open-op
`socks5:bind` command gate (ADR-006, resolved from the open-op
identity at the pump handler, passed as `can_bind` to the
substrate-free entry), the phase model (ADR-007 / POC #5 — accept
substrate-free entry), the phase model (ADR-007 / POC #5: accept
factory → reply#1 carrying BND.ADDR → the single-shot quiet wait
(two exits; any byte = garbage, EOF = client-gone teardown riding the
accept future's drop) → reply#2 carrying the peer → `pump_bidi`
+18 -18
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-producer-connect
name: "Core — producer half: establisher + dispatch + registration + CONNECT"
name: "Core: producer half, establisher + dispatch + registration + CONNECT"
status: completed
depends_on: [implementation/core-seam-types, implementation/core-params-errors, implementation/core-test-harness]
scope: moderate
@@ -14,7 +14,7 @@ level: implementation
Implement the producer half of `src/producer.rs` per producer.md:
the session-gate establisher, the command-dispatch pump handler, the
registration surface, and the CONNECT data plane (POC #1's validated
scope). This is the crate's core task — the open op all three
scope). This is the crate's core task: the open op all three
commands ride.
Deliverables (port from `/workspace/alksocks-connect-poc/src/
@@ -22,9 +22,9 @@ producer.rs` + `/workspace/alksocks-udp-poc/src/producer.rs`, adapted
to the crate's seam types and alkcall 0.8.0):
1. **The session-gate establisher** (`socks5_session_establisher`):
validates/records the opener identity (the CF-005/CF-006 seam —
validates/records the opener identity (the CF-005/CF-006 seam in
ADR-005) and applies session-level policy. Runs
`Establishment::default()`-shaped (no plan payload — the dial
`Establishment::default()`-shaped (no plan payload; the dial
happens post-establishment, so there is nothing to thread
establisher→handler; POC #1's wire note). Optional
`identity_witness` probe seam (the alktunnels posture) for tests.
@@ -33,17 +33,17 @@ to the crate's seam types and alkcall 0.8.0):
`BiStream` (`accept_no_auth → read_command`) → dispatch:
- **CONNECT** → invoke `DialFn(target)` at the command-read
interception point; success: `reply_success` then `pump_bidi`
**awaited inline** (R-02 — the returned `JoinHandle` tracks the
**awaited inline** (R-02: the returned `JoinHandle` tracks the
data-plane lifetime; early return = teardown-at-birth). Refusal:
`reply_error` with the mapped RFC code (dial-failure →
`ConnectionRefused`; scope-class → `ConnectionNotAllowed`) — no
`ConnectionRefused`; scope-class → `ConnectionNotAllowed`), no
phantom data plane.
- **UDP ASSOCIATE / TCP BIND** → this task lands the dispatch
arms as RFC-faithful refusals (`CommandNotSupported`); the
associate/bind producer tasks replace them. (The dispatch
skeleton exists from day one so the regression guard — one
registration, all three commands — is testable early.)
- The handler is substrate-agnostic by construction — it sees
skeleton exists from day one so the regression guard (one
registration, all three commands) is testable early.)
- The handler is substrate-agnostic by construction: it sees
boxed halves, never a socket type.
3. **`register_socks5_openable`**: spec + establisher + pump via
`register_openable_with_establisher` (`timeout: None` = the 10s
@@ -51,7 +51,7 @@ to the crate's seam types and alkcall 0.8.0):
before the establisher). Missing callbacks = those commands
refuse RFC-faithfully (`CommandNotSupported`-class), never a
panic (ADR-008).
4. **`serve_socks5_over<T>`** — the substrate-free protocol entry
4. **`serve_socks5_over<T>`**: the substrate-free protocol entry
point (the POC shape): generic over `T: AsyncRead + AsyncWrite +
Unpin`, driving the typestate and dispatching. This is what makes
the producer testable without channels (the differential-suite
@@ -97,7 +97,7 @@ to the crate's seam types and alkcall 0.8.0):
> Implementation notes (2026-09-23):
>
> - `Socks5ProducerBuilder` was added beyond the deliverable list —
> - `Socks5ProducerBuilder` was added beyond the deliverable list:
> it is the spec'd assembly shape (producer.md §Wiring, the
> `Socks5Producer::builder()` sketch); `register_socks5_openable`
> stays as the explicit per-registration form. Both ride the same
@@ -105,15 +105,15 @@ to the crate's seam types and alkcall 0.8.0):
> `register`/`serve_socks5_over` delegate to them verbatim.
> - The `establisher` accepts `Option<IdentityWitness>` (the
> alktunnels `*_with_witness` posture) rather than a required
> witness — the witness is a test seam, not a protocol surface;
> witness, because the witness is a test seam, not a protocol surface;
> `register_socks5_openable` threads it through.
> - The ASSOCIATE arm carries the `EgressFactory` parameter (and the
> BIND arm the `BindAcceptFactory`) in the registration signature
> from day one — the associate/bind tasks replace the refusal arms
> from day one, so the associate/bind tasks replace the refusal arms
> without touching the registration shape (no churn for the
> callers). Until those tasks land, the seams are accepted and
> their commands refuse `CommandNotSupported` (the pre-vertical
> posture, ADR-008's missing-callback class) — tested.
> posture, ADR-008's missing-callback class), tested.
> - CONNECT's refusal error shape: dial-seam refusal returns
> `SocksServerError::Bug("dial refused in-stream")` after
> `reply_error` (the refusal was already RFC-reported; the error
@@ -124,10 +124,10 @@ to the crate's seam types and alkcall 0.8.0):
## Summary
> Completed 2026-09-23 (commit on `main`). `src/producer.rs` filled:
> the session-gate establisher ([`socks5_session_establisher`] —
> the session-gate establisher ([`socks5_session_establisher`];
> CF-005/CF-006 witness seam, `Establishment::default()` per POC #1's
> no-plan wire note), the command-dispatch pump handler
> ([`make_socks5_pump_handler`] — `accept_bi` → typestate
> ([`make_socks5_pump_handler`]: `accept_bi` → typestate
> (`accept_no_auth → read_command`) → dispatch), the CONNECT data
> plane (`DialFn` at the command-read interception point →
> `reply_success(sentinel)` → `pump_bidi` awaited inline, R-02),
@@ -137,7 +137,7 @@ to the crate's seam types and alkcall 0.8.0):
> substrate-free entry), and the builder assembly shape.
>
> Dispatch arms: CONNECT serves; ASSOCIATE/BIND refuse
> `CommandNotSupported` (the pre-vertical posture — the skeleton
> `CommandNotSupported` (the pre-vertical posture; the skeleton
> exists so the regression guard is testable early; the associate/
> bind tasks replace the arms).
>
@@ -148,7 +148,7 @@ to the crate's seam types and alkcall 0.8.0):
> shape) + 5 harness integration (end-to-end CONNECT echo through
> channels with the identity witness, dial refusal 0x05 with no
> phantom data plane, scope-class refusal 0x02, ASSOCIATE/BIND
> refusals with CONNECT still serving — the one-registration
> refusals with CONNECT still serving (the one-registration
> regression guard, repeated CONNECTs on one registration). The
> harness's `RegisterMode` gained the real-producer mode; the
> minimal CONNECT stand-in is retained only as a harness-internal
+25 -25
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-seam-types
name: Core — the three seam types (DialFn / Egress / BindAccept families)
name: "Core: the three seam types (DialFn / Egress / BindAccept families)"
status: completed
depends_on: [implementation/core-crate-deps]
scope: moderate
@@ -14,50 +14,50 @@ level: implementation
Implement `src/egress.rs` per producer.md §The Three Seams and
ADR-004: the three I/O-plane seam types as function-shaped callbacks
with their error vocabularies. The protocol layer never names a
substrate type — these types are the inversion point; the batch-2
substrate type. These types are the inversion point; the batch-2
providers (local/composed) and the test harness all construct them.
Deliverables (port from the POCs — the shapes are POC-validated):
Deliverables (port from the POCs; the shapes are POC-validated):
1. **The dial seam (CONNECT)** — from
1. **The dial seam (CONNECT)**, from
`/workspace/alksocks-connect-poc/src/producer.rs` +
`/workspace/alksocks-udp-poc/src/producer.rs`:
- `TargetHandle { read, write }` — boxed halves
- `TargetHandle { read, write }`, i.e. boxed halves
(`Box<dyn AsyncRead + Unpin + Send>`,
`Box<dyn AsyncWrite + Unpin + Send>`; `+ Sync` per the
ChannelPlan payload bound). Ownership rides the handle
structurally (ADR-004).
- `DialFn` — `Arc<dyn Fn(TargetAddr) -> BoxFuture<'static,
- `DialFn`: `Arc<dyn Fn(TargetAddr) -> BoxFuture<'static,
Result<TargetHandle, SocksDialError>> + Send + Sync>`.
- `SocksDialError` with faithful `to_reply_error()` mapping
(refused → `ConnectionRefused`, not-allowed →
`ConnectionNotAllowed`, unreachable → `NetworkUnreachable`,
general → `GeneralFailure` — the POC's mapping, RFC-faithful
general → `GeneralFailure` (the POC's mapping, RFC-faithful
per AGENTS.md convention 2).
2. **The egress seam (ASSOCIATE)** — from
2. **The egress seam (ASSOCIATE)**, from
`/workspace/alksocks-udp-poc/src/egress.rs`:
- `Egress` trait: `send(addr, payload)` (payload-only — the frame
- `Egress` trait: `send(addr, payload)` (payload-only; the frame
codec lives on the wire side) + `next_reply()` returning *inner*
bytes (`addr + data`).
- **The readiness contract is load-bearing API surface** (POC #2's
structural finding): `next_reply` must be `Pending` when no
reply is ready — an instantly-ready `Ok(None)` starves a biased
reply is ready: an instantly-ready `Ok(None)` starves a biased
select into livelock. Document it on the trait; the contract
test lands with the producer-associate task.
- `EgressFactory` — called once per ASSOCIATE; the stage owns the
- `EgressFactory`: called once per ASSOCIATE; the stage owns the
egress; stage exit drops it.
- `EgressError` with `to_reply_error()` mapping.
3. **The accept seam (BIND)** — from
3. **The accept seam (BIND)**, from
`/workspace/alksocks-bind-poc/src/accept.rs`:
- `BindAccept { bind_addr: SocketAddr, accept: BoxFuture<'static,
Result<BindTargetHandle, BindAcceptError>> }` — reply#1's
Result<BindTargetHandle, BindAcceptError>> }`: reply#1's
BND.ADDR carrier + the one-inbound-accept future. **The accept
future owns its resources end-to-end**; the quiet wait's
`select!` drop IS the client-gone teardown (ADR-004/ADR-007).
- `BindTargetHandle` — accepted halves + peer address; ownership
- `BindTargetHandle`: accepted halves + peer address; ownership
rides the handle structurally (the third teardown-at-birth
witness — no `_` bindings of owning values).
- `BindAcceptFactory` — called once per BIND with the requested
witness: no `_` bindings of owning values).
- `BindAcceptFactory`: called once per BIND with the requested
target.
- `BindAcceptError` (accept-stage, post-reply#1 → reply#2) and
`BindAcceptFactoryError` (pre-reply#1 → reply#1), each with
@@ -103,7 +103,7 @@ with the producer-associate task (it needs the stage).
- `src/egress.rs`: all three seam families per the POC shapes.
- **Dial (CONNECT)**: `TargetHandle {read, write}` (boxed halves
`Send + Sync + Unpin` — the `+ Sync` per the `ChannelPlan` payload
`Send + Sync + Unpin`, with the `+ Sync` per the `ChannelPlan` payload
bound), `DialFn` (`Arc<dyn Fn(TargetAddr) -> BoxFuture<'static,
Result<TargetHandle, SocksDialError>> + Send + Sync>`), and
`SocksDialError` with the POC #1 five-variant mapping
@@ -126,16 +126,16 @@ with the producer-associate task (it needs the stage).
planned.
- **Accept (BIND)**: `BindAccept {bind_addr, accept}` (the
`accept()` consumer awaits the boxed future exactly once; the
future owns its resources end-to-end — the quiet wait's drop IS
future owns its resources end-to-end; the quiet wait's drop IS
the client-gone teardown), `BindTargetHandle {peer, read, write}`
(ownership structural; no `_` owner field — keep-alive state
(ownership structural; no `_` owner field, since keep-alive state
rides inside the halves as a provider-side owner wrapper, which
is the substrate-agnostic way to carry the POC's `_session`
without naming a substrate type in the protocol layer),
`BindAcceptFactory`, and the two-stage error pair
(`BindAcceptFactoryError` pre-reply#1 → reply#1;
`BindAcceptError` post-reply#1 → reply#2; `Shortage`→
`GeneralFailure` — no dedicated RFC code for shortage).
`GeneralFailure` (there is no dedicated RFC code for shortage).
- No trait for the seams themselves (ADR-004); the re-evaluation
threshold (three real trait impls) is stated in the module docs.
- No substrate type named anywhere in the module (grep-verified:
@@ -146,10 +146,10 @@ with the producer-associate task (it needs the stage).
need it in scope).
- Deviations from the POC code, all deliberate: `BindTargetHandle`
drops the POC's `_session: Option<TunnelSession>` field (substrate
types cannot appear here — ownership rides the halves; see the
types cannot appear here; ownership rides the halves (see the
dial bullet), `BindAccept` exposes `bind_addr()` as a getter
instead of a public field (the accept future is already private —
the struct now encapsulates both), and the test halves use
instead of a public field (the accept future is already private,
so the struct now encapsulates both), and the test halves use
`tokio::io::duplex` (test-only; the POCs' `std::io` empty/sink do
not implement tokio's traits).
@@ -157,10 +157,10 @@ with the producer-associate task (it needs the stage).
> Filled on completion (2026-09-23).
Implemented `src/egress.rs` — the three I/O-plane seam types (ADR-004),
Implemented `src/egress.rs`, the three I/O-plane seam types (ADR-004),
the protocol layer's inversion point: the dial seam (`TargetHandle`
boxed halves, `DialFn`, `SocksDialError` with the five-variant
RFC-faithful mapping), the egress seam (the one trait, `Egress` —
RFC-faithful mapping), the egress seam (the one trait, `Egress`:
`send` payload-only, `next_reply` returning inner bytes with the
load-bearing readiness contract documented in rustdoc; `EgressFactory`
+ the `egress_factory` sync-lift constructor; `EgressError`), and the
+39 -38
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-test-harness
name: Core — integration test harness (duplex transport + identities + in-process seams)
name: "Core: integration test harness (duplex transport + identities + in-process seams)"
status: completed
depends_on: [implementation/core-seam-types, implementation/core-params-errors]
scope: moderate
@@ -11,8 +11,8 @@ level: implementation
## Description
Build `tests/harness.rs` — the producer↔consumer wiring over a
`tokio::io::duplex` carrying the channels 8-byte chunk format — so
Build `tests/harness.rs`, the producer↔consumer wiring over a
`tokio::io::duplex` carrying the channels 8-byte chunk format, so
every subsequent core task tests end-to-end without the `local`
feature. This is the alkcall-0.8.0-first-consumption task: the POCs
ran 0.7.1; the harness proves the 0.8.0 establishment surface
@@ -20,78 +20,78 @@ ran 0.7.1; the harness proves the 0.8.0 establishment surface
`ChannelClient::open_channel`) works for this crate's shape before
the producer task builds on it. Template:
`/workspace/@alkdev/alktunnels/tests/harness.rs` (the sibling's
harness — same topology, adapted to this crate's seams).
harness, same topology, adapted to this crate's seams).
Topology (the alktunnels harness shape):
- **producer** (connect side of the channels connection — runs
- **producer** (connect side of the channels connection; runs
`ChannelClient::from_connection_with_serving`): serves the socks5
open op + generic channel ops on its serving registry. Its manager
allocates the channel IDs (`ChannelSide::Connect` — the serving
side allocates; the initiator adopts).
allocates the channel IDs (`ChannelSide::Connect`, since the serving
side allocates and the initiator adopts).
- **consumer** (accept side): the `ChannelsAdapter` + capturing
install hook. The hook captures the consumer's channel-0
`CallConnection` and `ChannelManager`.
- **The POC #1 wiring requirement is load-bearing here** (producer.md
§Harness wiring requirement): the accept-side install hook must
call `channel0_conn.set_identity(resolved)` before building the
`CallConnection` — without it, scope-gated opens fail closed with
`CallConnection`; without it, scope-gated opens fail closed with
`FORBIDDEN "authentication required"` (correct behavior, implicit
wiring). The harness makes the requirement explicit and tested.
Deliverables:
1. **Identities**: `consumer_identity()` (`socks5:open` only),
`bind_identity()` (`socks5:bind` only — the command-gate probe),
`both_scopes_identity()` (both grants, explicitly — never implied,
`bind_identity()` (`socks5:bind` only, the command-gate probe),
`both_scopes_identity()` (both grants, explicitly and never implied,
ADR-006), plus a `TestIdProvider` resolving them (the alktunnels
harness shape).
2. **In-process seam closures** (no sockets — the protocol layer is
2. **In-process seam closures** (no sockets; the protocol layer is
substrate-agnostic, so the harness dials in-process):
- `echo_dial()` — a `DialFn` returning duplex-halves wired to an
- `echo_dial()`: a `DialFn` returning duplex-halves wired to an
in-process echo server (the POC #1 shape); plus
`refusing_dial()` (a failing dial for the refusal-mapping
tests).
- `echo_egress()` — an `EgressFactory` whose egress echoes
- `echo_egress()`: an `EgressFactory` whose egress echoes
datagrams back (the POC #2 shape, readiness-contract-clean:
parks on a channel `recv()`, `Pending` when nothing is ready).
- `echo_accept()` — a `BindAcceptFactory` whose accept resolves
- `echo_accept()`: a `BindAcceptFactory` whose accept resolves
immediately with in-process halves (the POC #5 shape); plus a
failing variant.
3. **The producer registration helper**: a `spawn_producer(...)`
function wiring `register_socks5_openable` (or its task-6
successor) with the harness seams — the single place the
successor) with the harness seams, the single place the
producer-side wiring lives, so the per-command tasks only write
tests.
4. **A raw RFC client helper**: a hand-rolled minimal RFC 1928
client over any `AsyncRead + AsyncWrite` (greeting → method
selection → command with target → reply read) — the differential
suite's established pattern — so producer tasks test without
selection → command with target → reply read) following the differential
suite's established pattern, so producer tasks test without
depending on the consumer session types (which land later).
5. **One smoke test** proving the full loop: harness up → producer
registered → consumer opens a channel → raw RFC CONNECT
conversation → echo round-trip. (This test needs a minimal
CONNECT producer path; if the producer task hasn't landed, stub
the handler with the POC #1 `serve_socks5_over` shape inline in
the harness — the producer task then replaces it. Alternatively
the harness, and the producer task then replaces it. Alternatively
sequence this task to land immediately before the producer task
and let the smoke test be the producer task's first test — the
and let the smoke test be the producer task's first test; the
implementer's judgment; the acceptance bar is the harness
compiling with the identities + seams + helpers tested.)
## Acceptance Criteria
- [x] Harness topology works over alkcall 0.8.0 (the establishment
surface consumed successfully — the first-consumption risk
surface consumed successfully; the first-consumption risk
retired)
- [x] Identity wiring explicit: `set_identity` in the install hook;
a scope-less identity gets `FORBIDDEN` (fails closed) — tested
a scope-less identity gets `FORBIDDEN` (fails closed), tested
- [x] All three identities + all in-process seam closures unit-tested
- [x] The raw RFC client helper round-trips a greeting + command +
reply against a hand-rolled server over a duplex
- [x] No `local` feature, no sockets — everything in-process
- [x] No `local` feature, no sockets; everything in-process
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
(the harness is dev-only — verify it stays out of the wasm
(the harness is dev-only; verify it stays out of the wasm
build)
## References
@@ -112,7 +112,7 @@ Deliverables:
- `tests/harness.rs`: the producer↔consumer wiring over one
`tokio::io::duplex` carrying the channels 8-byte chunk format, in
the task's topology — **producer = connect side**
the task's topology: **producer = connect side**
(`ChannelClient::from_connection_with_serving`; its manager
allocates the odd channel IDs), **consumer = accept side** (the
`ChannelsAdapter` + capturing install hook; the hook captures the
@@ -126,15 +126,16 @@ Deliverables:
channel survives on either side (fail-closed).
- Identities: `consumer_identity()` (`socks5:open` only),
`bind_identity()` (`socks5:bind` only), `both_scopes_identity()`
(both, explicit — ADR-006), `TestIdProvider` +
(both, explicit, per ADR-006), `TestIdProvider` +
`TEST_AUTH_TOKEN` (the token-resolver path, unit-tested both
directions).
- In-process seams (no sockets, no `local`): `echo_dial()`
(echo-per-dial, exercised through `pump_bidi` — the producer data
(echo-per-dial, exercised through `pump_bidi`, the producer data
(echo-per-dial, exercised through `pump_bidi`, the producer data
plane's shape), `refusing_dial(err)` (variant-preserving per-call
reconstruction — the seam errors are not `Clone`),
reconstruction, as the seam errors are not `Clone`),
`echo_egress()` (a `ChanEgress` whose `next_reply` parks on
`mpsc::UnboundedReceiver::recv()` — `Pending` when idle, the
`mpsc::UnboundedReceiver::recv()`: `Pending` when idle, with the
readiness contract verified with a 50ms timeout probe),
`refusing_egress(err)`, `echo_accept()` (reply#1 advertises a
static in-process BND.ADDR, `ECHO_BIND_PORT` 2121; the accept
@@ -144,7 +145,7 @@ Deliverables:
- `register_socks5_openable_minimal` + `socks5_session_establisher`:
the producer-side wiring helper over alkcall 0.8.0's
`ChannelCore::register_openable_with_establisher` (spec from
`socks5_open_spec()`, `Establishment::default()` — session gate
`socks5_open_spec()`, `Establishment::default()` for a session gate
only; the dial is in-stream). The establisher witnesses the
per-call opener identity id (CF-006) into an `IdentityWitness`,
asserted end-to-end in the smoke test.
@@ -168,37 +169,37 @@ Deliverables:
`consumer_call.call(OP_SOCKS5_OPEN, Socks5Params::as_json())` and
adopts on `consumer_manager` (the alktunnels `wire_with` +
`open_reverse_channel` shape) rather than
`ChannelClient::open_channel` — in this topology the open op is
`ChannelClient::open_channel`: in this topology the open op is
served by the producer's registry and reached from the accept
side's channel-0 call surface; `open_channel` remains the
consumer-session surface for the consumer task's session types.
- The harness is `tests/harness.rs` only (dev-only) — verified out
- The harness is `tests/harness.rs` only (dev-only), verified out
of the wasm build by the targeted checks below.
## Summary
> Filled on completion (2026-09-23).
Built `tests/harness.rs` — the integration test harness: the
Built `tests/harness.rs`, the integration test harness: the
producer↔consumer topology over one `tokio::io::duplex` (producer =
connect side/serving; consumer = accept side/adapter + capturing
hook), the alkcall 0.8.0 establishment surface consumed for the
first time by this crate (`register_openable_with_establisher`,
`Establishment::default`, the consumer-side call + adopt dance) —
`Establishment::default`, the consumer-side call + adopt dance), and
the first-consumption risk retired with 14 green tests.
- The three identities + `TestIdProvider` (all unit-tested).
- All in-process seam closures: `echo_dial`/`refusing_dial` (variant-
preserving), `echo_egress` (readiness contract verified — a 50ms
preserving), `echo_egress` (readiness contract verified: a 50ms
timeout probe proves `next_reply` parks, never a hot `Ok(None)`) /
`refusing_egress`, `echo_accept` (immediate resolve + echo) /
`refusing_accept_factory` / `refusing_accept` (two-stage refusal
shapes, mapping-table-verified through `to_reply_error()`).
- The producer registration helper (`register_socks5_openable_minimal`
+ `socks5_session_establisher` + `RegisterMode`) — the single place
+ `socks5_session_establisher` + `RegisterMode`), the single place
the producer-side wiring lives; the CF-006 identity witness rides
it and is asserted.
- `RawRfcClient` — the raw RFC 1928 client helper over any
- `RawRfcClient`: the raw RFC 1928 client helper over any
`AsyncRead + AsyncWrite`, round-tripped against a hand-rolled
byte-level server (success + refusal replies).
- The smoke test: harness up → producer registered → consumer open +
@@ -211,5 +212,5 @@ Verification: `cargo test` (127 green: 76 lib + 14 harness + 37
rfc1928-differential), `cargo test --all-features` green, `cargo
clippy --all-targets -- -D warnings` clean, `cargo fmt --check`
clean, `cargo doc --no-deps` zero warnings, both wasm checks
(`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean —
the harness stays out of the wasm build.
(`cargo check` + `cargo clippy` on `wasm32-unknown-unknown`) clean,
and the harness stays out of the wasm build.
+19 -19
View File
@@ -1,6 +1,6 @@
---
id: implementation/core-wire-codec
name: Core — the ASSOCIATE datagram frame codec (src/wire.rs)
name: "Core: the ASSOCIATE datagram frame codec (src/wire.rs)"
status: completed
depends_on: [implementation/core-crate-deps]
scope: narrow
@@ -15,12 +15,12 @@ Implement `src/wire.rs` per wire.md §The Data Plane and bast.md: the
first-party `[len: u16 BE][addr + data]` frame codec for the UDP
ASSOCIATE datagram stage (ADR-003), re-implemented in first-party
code shaped on alktunnels' length codec (`/workspace/@alkdev/
alktunnels/src/wire.rs` — `frame_datagram`/`DatagramReader` — same
alktunnels/src/wire.rs`, the `frame_datagram`/`DatagramReader` pair, with the same
F-2 behavior, but the payload here is the RFC 1928 address encoding
plus data, not a bare payload).
The codec is the crate's one framed stage (CONNECT/BIND are raw
pass-through — no framing). Framing is applied **exactly once, by
pass-through, with no framing). Framing is applied **exactly once, by
this layer**: `Egress::next_reply` returns *inner* bytes
(`addr + data`); the producer's frame writer adds the single length
prefix (the double-framing bug class POC #2 caught is an
@@ -29,35 +29,35 @@ API-design invariant this module's docs must state).
Deliverables:
1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result<Bytes,
WireError>`** — one frame per datagram: `[len: u16 BE][addr +
WireError>`**, one frame per datagram: `[len: u16 BE][addr +
data]` where `addr + data` is `TargetAddr::to_be_bytes()` (ATYP +
addr + port) followed by the payload.
2. **The joint bound (the load-bearing constraint)**: reject
`Oversize` at frame time whenever `len(addr) + len(payload) >
65535`. The UDP payload bound alone (65507) does NOT fit u16
unconditionally — a 65507-byte datagram to a 255-character domain
unconditionally: a 65507-byte datagram to a 255-character domain
is 65766 frame bytes. Max data: 65276 for domain-form
destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16
arithmetic on the payload bound (wire.md's explicit requirement).
Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain)
so callers can compute the joint bound.
3. **`DatagramReader`** — the incremental decoder: feed it whatever
3. **`DatagramReader`**, the incremental decoder: feed it whatever
`read()` yields; it reassembles frames across chunk boundaries
(split frames, batched frames, partial headers). Returns
`(TargetAddr, Bytes)` per completed frame — it decodes the addr
`(TargetAddr, Bytes)` per completed frame; it decodes the addr
sub-layout (ATYP determines the address length) and splits
`addr + data`. Infallible decode direction (the oversize bound is
enforced at frame time); `is_mid_frame()` diagnostic for teardown.
4. **The F-2 invariant, correctly stated**: a **zero-length payload**
is a legal empty datagram — never EOF. (`len == 0` is unreachable
is a legal empty datagram, and never EOF. (`len == 0` is unreachable
on this wire since `addr` is always ≥ 7 bytes; the invariant that
matters is payload-level. See the doc-sync deliverable.)
5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0`
is a legal empty datagram" while also pinning that `len` covers
`addr + data` (joint bound) — `len = 0` is unreachable since
`addr + data` (joint bound); `len = 0` is unreachable since
`addr` ≥ 7 bytes. Correct both docs to state the operative
invariant at payload level ("a zero-length payload is a legal
empty datagram, never EOF — the F-2 invariant") in the same
empty datagram, never EOF, per the F-2 invariant") in the same
change; do not silently diverge.
6. **EOF layering note in the module docs**: EOF is the
channels-level sentinel on the `BiStream`, outside this codec;
@@ -69,7 +69,7 @@ split across awkward chunks, batched frames in one chunk, partial
header waits, joint-bound rejection (the 65507 + 255-char-domain
case), oversize at frame time, mid-frame state observable, garbage
ATYP decode error (the `udp_undecodable_frame_ends_association`
shape — the decode error type must distinguish a bad ATYP so the
shape: the decode error type must distinguish a bad ATYP so the
producer stage can fail the association loud).
## Acceptance Criteria
@@ -97,7 +97,7 @@ producer stage can fail the association loud).
- docs/architecture/decisions/003-udp-handle-swap.md (framing-once,
F-2)
- Template: `/workspace/@alkdev/alktunnels/src/wire.rs` (the length
codec + its test matrix — shape on it, do not depend on it)
codec + its test matrix: shape on it, do not depend on it)
- POC sketch: `/workspace/alksocks-udp-poc/src/datagram.rs`
(`frame`/`inner`/`unframe`/`addr_encoding_len`)
@@ -111,14 +111,14 @@ producer stage can fail the association loud).
`Result`s rather than panicking or skipping: reassembly is
infallible (oversize enforced at frame time bounds the buffer by
the u16 declared length), so the only failure per completed frame
is the addr decode, typed distinctly — `BadAtyp(u8)` (the
is the addr decode, typed distinctly: `BadAtyp(u8)` (the
`udp_undecodable_frame_ends_association` shape, POC #2),
`ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0` —
`ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0`,
unreachable from a compliant peer; protocol garbage). The reader
consumes the frame's bytes either way and keeps going; the caller
fails the association loud on the first error.
- **The joint bound**: `frame` computes `len(addr) + len(payload)`
with `checked_add` (saturating past the bound — no unchecked u16
with `checked_add` (saturating past the bound, with no unchecked u16
arithmetic) and rejects `Oversize(total, max)` when it exceeds
`MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) /
`4 + domain.len()` (domain, 259 max) so callers compute the joint
@@ -127,7 +127,7 @@ producer stage can fail the association loud).
per-form maxima at the exact limit (65276 domain / 65516 v6 /
65528 v4) and one-over rejection.
- **Decode direction**: hand-parsed addr sub-layout over the borrowed
frame (the sync mirror of the forked `read_address` — the stage's
frame (the sync mirror of the forked `read_address`, since the stage's
decode must not be async), UTF-8-validated like the fork's codec,
payload = the remainder. `TargetAddr::to_be_bytes()`'s output is
the encode direction verbatim.
@@ -136,10 +136,10 @@ producer stage can fail the association loud).
state the F-2 invariant at payload level ("a zero-length payload is
a legal empty datagram, never EOF"), with `len == 0` (zero inner
bytes) noted unreachable from a compliant peer since `addr` ≥ 7
bytes — the residue corrected in this same change.
bytes, and the residue was corrected in this same change.
- A first cut returned bare `(TargetAddr, Bytes)` per frame; the
garbage-ATYP deliverable (typed decode error, not a panic) forced
the `Result` per frame — the shape the producer stage's loud-fail
the `Result` per frame, which is the shape the producer stage's loud-fail
needs.
## Summary
@@ -173,4 +173,4 @@ buffering, all four decode-error shapes); lib.rs re-exports
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`cargo doc --no-deps`, `cargo check --target
wasm32-unknown-unknown`, `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` — all clean.
wasm32-unknown-unknown -- -D warnings`: all clean.
+33 -33
View File
@@ -1,6 +1,6 @@
---
id: implementation/door-tcp
name: Front doors — the TCP door (raw-pipe + RFC 1929 cred-bearing) and the BIND door
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
@@ -12,23 +12,23 @@ 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
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
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
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
local RFC conversation itself: `accept_password_auth`-shaped
(the forked surface: `Socks5ServerProtocol::start` →
`negotiate_auth(&[PasswordAuthentication])` →
`read_username_password` → check → `accept`/`reject`), then
@@ -46,18 +46,18 @@ Deliverables:
- **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` — the door's per-credential identity rides the
`&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
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 — ADR-005).
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 — the
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
@@ -65,7 +65,7 @@ Deliverables:
- **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 — the cap regime does not bound it); plus
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.
@@ -74,7 +74,7 @@ Deliverables:
.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 — the alktunnels
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 →
@@ -94,18 +94,18 @@ Deliverables:
--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
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
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 (the producer's establisher witness
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
config, pinned by tests
- [x] Per-connection failure isolation, pinned by test
## References
@@ -118,26 +118,26 @@ Deliverables:
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
`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
- **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
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
identity's *id* (the resolution seam's vocabulary); the password
never rides it). Rationale: one transport, one call connection,
many identities — a per-credential `ChannelClient` would need a
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
@@ -150,22 +150,22 @@ Deliverables:
`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
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
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
`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
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
@@ -173,29 +173,29 @@ Deliverables:
`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** — 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 — raw piping would misparse the
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 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
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).
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
`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
@@ -208,14 +208,14 @@ Deliverables:
> 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
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 —
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
+28 -28
View File
@@ -1,6 +1,6 @@
---
id: implementation/door-udp
name: Front doors — the UDP door (the RFC codec boundary)
name: "Front doors: the UDP door (the RFC codec boundary)"
status: completed
depends_on: [implementation/door-tcp]
scope: narrow
@@ -12,22 +12,22 @@ level: implementation
## Description
The consumer's UDP front door (feature `local`): a real UDP socket
exposing an ASSOCIATE association to vanilla clients — the POC #2
exposing an ASSOCIATE association to vanilla clients, the POC #2
test-8 shape. The RFC UDP-header codec lives ONLY at this boundary
(ADR-003); the in-band wire carries `{addr, data}` frames.
Deliverables:
1. **`src/door.rs` additions** (feature `local`) — the UDP door:
- Bind a real UDP socket (the relay socket — the only place a
1. **`src/door.rs` additions** (feature `local`), the UDP door:
- Bind a real UDP socket (the relay socket, the only place a
real address exists; the vanilla client `sendto()`s it).
- Open the association in-band via `AssociateSession::associate`
(the reply's sentinel BND.ADDR is received and never used —
(the reply's sentinel BND.ADDR is received and never used, as
the handle is the channel, ADR-003).
- **The datagram pump** (two pumps, joined — the ADR-050
- **The datagram pump** (two pumps, joined, per the ADR-050
two-pump contract): socket→channel decodes the RFC header
(`parse_udp_request`), **FRAG ≠ 0 → refuse** (drop the
datagram; the v1 posture — fragmentation cannot be expressed
(`parse_udp_request`), and **FRAG ≠ 0 → refuse** (drops the
datagram; the v1 posture, since fragmentation cannot be expressed
in-band), sends `{addr, data}` in-band
(`send_datagram`); channel→socket encodes the reply header
(`new_udp_header`) and sends the RFC-header'd datagram back to
@@ -36,21 +36,21 @@ Deliverables:
door's association ends when the channel ends; the door's UDP
socket closes with it. A per-client idle bound (no datagrams
within a window → close the association) is the door-side
defense (ADR-014) — constructor config, mirroring the TCP
defense (ADR-014), constructor config, mirroring the TCP
door's knobs.
- The door's bind address is constructor config (consumer.md:
"the UDP door's bind address is assembly-layer configuration").
- Surface shape: follow the TCP door's builder (`FrontDoor`
additions or a sibling `UdpFrontDoor` — decide at
additions or a sibling `UdpFrontDoor`; decide at
implementation against the landed `door.rs` shape and record
in Notes; the spec leaves the exact constructor shape open).
2. **Tests** (`tests/door-udp.rs`): the POC #2 test-8 shape end to
end — a real UDP echo target, the producer with `local::udp_
end: a real UDP echo target, the producer with `local::udp_
egress()`, the door, a vanilla RFC-header'd datagram in → the
echo back RFC-header'd (the vanilla client simulated at the RFC
boundary); FRAG ≠ 0 refused (dropped, association stays healthy);
the empty-datagram invariant through the door (a zero-length
payload round-trips, never EOF — F-2); the idle bound closes an
payload round-trips, never EOF, F-2); the idle bound closes an
idle association; a fresh association succeeds after teardown.
## Acceptance Criteria
@@ -60,14 +60,14 @@ Deliverables:
--check` clean
- [ ] Default wasm checks stay green (door code is `local`-gated)
- [ ] The RFC UDP-header codec appears only at the door boundary
(in-band stays `{addr, data}` frames) — pinned by test
- [ ] FRAG ≠ 0 refused at the door — pinned by test
(in-band stays `{addr, data}` frames), pinned by test
- [ ] FRAG ≠ 0 refused at the door, pinned by test
- [ ] The empty datagram (zero-length payload) round-trips through
the door, never EOF — pinned by test (the F-2 invariant at the
the door, never EOF, pinned by test (the F-2 invariant at the
vanilla boundary)
- [ ] Association lifetime = channel lifetime; teardown leaves the
connection healthy — pinned by test
- [ ] The idle bound is constructor config — pinned by test
connection healthy, pinned by test
- [ ] The idle bound is constructor config, pinned by test
## References
@@ -76,8 +76,8 @@ Deliverables:
- docs/architecture/consumer.md §The Front Doors (the UDP door),
§Wiring
- POC reference: `/workspace/alksocks-udp-poc/tests/udp_poc.rs`
(`udp_front_door_vanilla_client_round_trip` — the pump shape)
- Landed surfaces: `src/consumer.rs` (`AssociateSession` —
(`udp_front_door_vanilla_client_round_trip`, the pump shape)
- Landed surfaces: `src/consumer.rs` (`AssociateSession`,
`send_datagram`/`recv_datagram`/`split`), `src/rfc1928/mod.rs`
(`new_udp_header`/`parse_udp_request`), `src/wire.rs` (the frame
codec)
@@ -86,34 +86,34 @@ Deliverables:
- **Surface shape** (the spec left the constructor open): a **sibling**
`UdpFrontDoor` with its own builder (`bind` + `idle`), not
`FrontDoor` additions — the UDP door shares no accept loop with the
`FrontDoor` additions: the UDP door shares no accept loop with the
TCP door (UDP has no connections; one relay socket = one
association), and the shared shape would have forced a mode split on
every knob. `serve(channel_client)` + `serve_on(socket, client)`
(the test/assembly seam), mirroring the TCP door.
- **The pump shape** (an implementation finding worth recording): the
POC #2 shape (both pumps sharing one `AssociateSession` behind a
mutex) deadlocks in this landed surface — the channel read parks
mutex) deadlocks in this landed surface: the channel read parks
holding the lock while the socket side's send needs it. The door
instead **splits the association's stream** (`AssociateSession::split`
— the door bridge seam): one pump owns the in-band write half
instead **splits the association's stream** (`AssociateSession::split`,
the door bridge seam): one pump owns the in-band write half
(socket→channel, framing via the wire codec), the other the read
half (channel→socket, the incremental `DatagramReader` decode). The
two pumps join via `select!` (whichever ends first closes the door);
the pump is inlined in `serve_on` because the split halves borrow
the session (the borrow must end before the teardown's
`shutdown`). The read half's decode carries a fresh per-call
`DatagramReader` — the door serves one association per socket and
`DatagramReader`, because the door serves one association per socket and
the relay loop drains one frame per call; the mid-frame persistence
case is the session's own `recv_datagram` contract (tested in
consumer-associate).
- **The client's source address** is learned from the first inbound
datagram (std mutex, held never across an await) and reused for
every reply — the relay's single-client shape. A reply before the
every reply, since the relay's shape is single-client. A reply before the
first inbound datagram is parked (none exists in practice).
- **FRAG ≠ 0** drops the datagram and keeps the association (the v1
posture); a malformed RFC header also drops (garbage never goes
in-band — the in-band refusal shape is the producer's
in-band; the in-band refusal shape is the producer's
`UndecodableFrame` exit, which the door never triggers by
construction).
- **The idle bound** covers the socket→channel pump's
@@ -124,7 +124,7 @@ Deliverables:
## Summary
Landed the UDP front door (`src/door.rs`, feature `local`):
`UdpFrontDoor` — a real relay UDP socket (constructor-config bind
`UdpFrontDoor`, a real relay UDP socket (constructor-config bind
address) bridged onto one in-band association
(`AssociateSession::associate`; the sentinel BND.ADDR received, never
used). The RFC UDP-header codec appears only at the door boundary
@@ -135,7 +135,7 @@ round-trips (F-2); association lifetime = channel lifetime (the door's
socket closes with the pump; teardown reaps the channel); the client
idle bound is constructor config and closes an idle association
door-side. Two-pump shape with the split-halves correction recorded in
Notes. Tests: `tests/door-udp.rs` (4 tests — the round-trip + empty
Notes. Tests: `tests/door-udp.rs` (4 tests: the round-trip + empty
datagram, FRAG refusal + health, idle bound + fresh-association
health, fresh-association-after-teardown). Verification: `cargo test`
green, `cargo test --all-features` green, clippy `-D warnings` clean
+29 -29
View File
@@ -1,6 +1,6 @@
---
id: implementation/fix-bind-reply2-reentry
name: "Fix — BIND reply#2 re-entry guard + consumer session hygiene (review-002 C-1, D-1, N-6, S-2)"
name: "Fix: BIND reply#2 re-entry guard + consumer session hygiene (review-002 C-1, D-1, N-6, S-2)"
status: completed
depends_on: []
scope: narrow
@@ -15,11 +15,11 @@ Close review 002's one reachable silent-corruption path plus the
consumer-side residues that ride the same file
(`docs/reviews/002-protocol-core-implementation-review.md`):
1. **C-1 (major) — the reply#2 phase is re-entrant with no
1. **C-1 (major): the reply#2 phase is re-entrant with no
spent-marker.** `BindSession::await_inbound`
(`src/consumer.rs:757-763`) takes `&mut self`, leaves `inner`
populated, and returns a `BindDataPlane<'_>` borrowing the same
stream — nothing marks the phase consumed. A second call runs
stream, and nothing marks the phase consumed. A second call runs
`read_reply2` against the *live data plane* (post-reply#2 bytes
are the accepted peer's raw application data): a baffling
`UnsupportedSocksVersion` at best, silent 10+-byte data
@@ -29,7 +29,7 @@ consumer-side residues that ride the same file
spent-state-guarded; this is the gap.
Fix per the review's sketch: make the reply#2 phase consumed in
the session's established posture — `inner.take()` (a second call
the session's established posture: `inner.take()` (a second call
hits the `None` arm and returns the existing `Ok(None)` spent
shape, which the method's doc *already* claims: "`None` after a
taken plane") or an equivalent spent-marker riding the session.
@@ -38,33 +38,33 @@ consumer-side residues that ride the same file
`Socks5ChannelSession::into_inner` already pins) while the
session stays the teardown owner for the channel entry. Either
mechanism is acceptable; the spent shape must match the session
family's posture (`Ok(None)` or the typed `SpentSessionError` —
pick one, document it).
family's posture (`Ok(None)` or the typed `SpentSessionError`; pick
one and document it).
2. **D-1 (minor) — `BindReply1::await_inbound` is dead surface**
2. **D-1 (minor): `BindReply1::await_inbound` is dead surface**
(0% covered; every test drives the session's method). The choice
interacts with C-1 — pick one normative reply#2 reader:
interacts with C-1, so pick one normative reply#2 reader:
- (a) the session's `await_inbound` (the review's lean: document
`reply1()` as a view, demote `BindReply1::await_inbound` to a
private helper or cut it), or
- (b) `BindReply1::await_inbound` (the task spec's intent —
- (b) `BindReply1::await_inbound` (the task spec's intent,
`tasks/implementation/core-consumer-bind.md:74-75`; the
session's method delegates to it and the handle is tested
through).
The alternate path must be impossible to double-read (private,
cut, or spent-guarded).
3. **N-6 (observation) — the three session constructors share a
3. **N-6 (observation): the three session constructors share a
near-identical prologue** (open → `BiStream::from_joined` →
negotiated → teardown-on-err; `src/consumer.rs:121-149`,
`290-324`, `702-733`). Collapse into one private
`open_and_negotiate` helper — the teardown-at-birth invariant in
`open_and_negotiate` helper, holding the teardown-at-birth invariant in
one place instead of three (a batch-2 constructor then cannot
hand-copy the reap path). Zero behavior change.
4. **S-2 (observation) — one doc line on the three sessions' `reap`**
4. **S-2 (observation): one doc line on the three sessions' `reap`**
(`src/consumer.rs:197-202`, `490-495`, `787-792`): the dropped
`teardown_channel` `JoinHandle` is deliberate — the producer-side
`teardown_channel` `JoinHandle` is deliberate: the producer-side
pump task finishes via EOF propagation (the graceful shape);
`clear_all` aborts instead (the substrate's abrupt path). No code
change; prevents a future reader "fixing" the handle into an
@@ -83,7 +83,7 @@ accept-failure mappings unaffected).
data-plane bytes asserted untouched
- [ ] The `reply1()`-twice re-entry path is closed (the D-1
decision: one normative reader; the alternate private/cut/
spent-guarded) — the choice recorded in the task Notes
spent-guarded), with the choice recorded in the task Notes
- [ ] The session types' docs match the landed spent posture (the
"`None` after a taken plane" claim is true of the code)
- [ ] The three constructors share one `open_and_negotiate` helper;
@@ -100,7 +100,7 @@ accept-failure mappings unaffected).
- tasks/implementation/core-consumer-bind.md (the landed BIND
session; the `BindReply1` handle's spec'd intent)
- tasks/implementation/review-protocol-core.md (R-2's
`SpentSessionError` shape — the consistency target)
`SpentSessionError` shape, the consistency target)
## Notes
@@ -109,34 +109,34 @@ accept-failure mappings unaffected).
- **C-1 landed as the take-shape** (the review's first sketch): `BindSession
::await_inbound` takes the session's inner stream before reading reply#2
(`inner.take()`); the returned `BindDataPlane` **owns** the
`Socks5Stream<BiStream>` while the session stays the teardown owner — the
`Socks5Stream<BiStream>` while the session stays the teardown owner; that is the
`TakenInner` pairing posture `Socks5ChannelSession::into_inner` pinned, now
carried by `BindDataPlane` itself (no new pairing type — the plane is the
carried by `BindDataPlane` itself (no new pairing type: the plane is the
pair's inner half; the session is the pair's teardown owner). A second
call hits the `None` arm → the family's spent shape `Ok(None)` (the
method's doc claim "`None` after a taken plane" is now true). The spent
shape chosen is **`Ok(None)`** (not a typed `SpentSessionError`): the
method's existing return type `Result<Option<...>>` already carried the
documented spent arm, and the sibling read-shaped surfaces
(`recv_datagram`'s spent stream) return the EOF-shape `Ok(None)` — the
(`recv_datagram`'s spent stream) return the EOF-shape `Ok(None)`, the
session family's read-side posture; `SpentSessionError` stays the
taking-methods' (`into_inner`) shape.
- **A failed reply#2 read restores the inner** (the phase is *not*
consumed on failure): the taken inner is returned to the session on
error — the channel stays owned (reply#1 succeeded, the listener
error, so the channel stays owned (reply#1 succeeded, the listener
existed, reap via `close`/`Drop`, the error-surface contract the
existing tests pin), and the session's `split()`/`close()` keep the
pipe. The corruption path is the *post-success* re-entry, and that
is exactly what the take-guard closes: after success the plane is
taken, the spent shape holds. A post-failure re-call re-reads
reply#2 against the (already-ended) session — the producer ends the
reply#2 against the (already-ended) session: the producer ends the
session on every post-reply#1 failure exit, so the read EOFs the
same way (the quiet-wait garbage test pins this shape, green
unchanged).
- **D-1 decision: option (a)** — the session's `await_inbound` is the one
- **D-1 decision: option (a)**: the session's `await_inbound` is the one
normative reply#2 reader. `BindReply1::await_inbound` is **cut** (not
private: the take-shape makes the handle's borrowed-plane reader
impossible — the plane now owns the stream); `reply1()` is documented as
impossible, since the plane now owns the stream); `reply1()` is documented as
a **view** (`bind_addr` + `raw_mut`, the front-door bridge seam), which
is exactly how the four existing call sites use it. `BindReply1` stays
as the typed view (its `Send` probe pinned).
@@ -153,21 +153,21 @@ accept-failure mappings unaffected).
`use_stream` → the negotiation closure → teardown-on-err, one place for
the teardown-at-birth invariant. The three constructors are one-liners
over it (CONNECT returns the inner; ASSOCIATE/BIND return
`(addr, inner)` tuples). Zero behavior change — the full consumer +
`(addr, inner)` tuples). Zero behavior change: the full consumer +
integration suites stayed green unchanged (the only touched call sites
were the two test-side ownership consequences above).
- **S-2 landed**: the same doc line on all three `reap` sites (CONNECT,
ASSOCIATE, BIND) — the dropped `teardown_channel` `JoinHandle` is
ASSOCIATE, BIND), saying the dropped `teardown_channel` `JoinHandle` is
deliberate (the producer-side pump finishes via EOF propagation, the
graceful shape; `clear_all` aborts instead, the substrate's abrupt path).
- **Test round**: the pinning integration test
(`double_await_inbound_returns_the_spent_shape_and_bytes_survive` — the
second call returns `Ok(None)` and the first plane's exchange completed
(`double_await_inbound_returns_the_spent_shape_and_bytes_survive`,
where the second call returns `Ok(None)` and the first plane's exchange completed
uncorrupted, a fresh bind round-trips), the `reply1()`-twice view test
(`reply1_twice_is_a_view_and_the_reader_yields_one_plane` — repeated
(`reply1_twice_is_a_view_and_the_reader_yields_one_plane`; repeated
views carry the same address, the reader yields exactly one plane, the
second reader call is the spent shape), the spent-BIND unit test
(`spent_bind_await_inbound_returns_the_spent_shape` — the `None`-inner
(`spent_bind_await_inbound_returns_the_spent_shape`, the `None`-inner
arm + `reply1`/`split` spent probes), and the `Send` probe updated
(`BindSession` + owned `BindDataPlane`). The existing BIND suite stayed
green unchanged apart from the one `into_inner` ownership call site
@@ -177,7 +177,7 @@ accept-failure mappings unaffected).
## Summary
Closed review 002's C-1 (the one reachable silent-corruption path): the
BIND reply#2 phase is take-guarded — `BindSession::await_inbound` takes
BIND reply#2 phase is take-guarded: `BindSession::await_inbound` takes
the session's stream, the returned `BindDataPlane` owns it (the
`TakenInner` pairing posture; the session stays the teardown owner), and
a second call returns the family's spent shape `Ok(None)` with no re-read
+38 -38
View File
@@ -1,6 +1,6 @@
---
id: implementation/fix-coverage-residue
name: "Tests — the review-002 coverage residue (N-8: stage exits, splits, spent arms)"
name: "Tests: the review-002 coverage residue (N-8: stage exits, splits, spent arms)"
status: completed
depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture]
scope: moderate
@@ -11,7 +11,7 @@ level: implementation
## Description
The review-002 coverage residue — the load-bearing gaps plus the
The review-002 coverage residue: the load-bearing gaps plus the
single-arm cosmetics, attributed in
`docs/reviews/002-protocol-core-implementation-review.md` §N-8 so
the next `cargo llvm-cov` run needs no re-derivation. Tests only;
@@ -22,52 +22,52 @@ vocabulary).
**The headline gaps** (the review's named priority):
1. **The datagram stage's three failure exits**
(`src/producer.rs:374-386`, `405-414`) — the ASSOCIATE teardown
(`src/producer.rs:374-386`, `405-414`): the ASSOCIATE teardown
surface, untested anywhere (the harness's egress never fails
post-reply):
- `ReplyFrameRejected` (an egress reply violating the joint
bound)
- `EgressEnded` via `next_reply`'s `Err` (the relay's end)
- `EgressEnded` via a failing `egress.send` — at minimum this
- `EgressEnded` via a failing `egress.send`; at minimum this
one gets the drop-witness treatment
`egress_factory_called_once_and_dropped_with_the_stage` gives
the happy path
- the stage's `Io` exit (a failing write/read)
2. **The two untested `split`s** (`src/consumer.rs:437`,
`771-774`) — `AssociateSession::split` and `BindSession::split`
`771-774`): `AssociateSession::split` and `BindSession::split`
on a *live* stream; the front-door-bridge surfaces
(consumer.md), load-bearing for the batch-2 UDP/BIND doors. One
test each (or the explicit "batch-2 door exercises it" note —
prefer the test; the doors are not here yet).
test each (or the explicit "batch-2 door exercises it" note;
prefer the test, since the doors are not here yet).
**The single-arm residue** (batch freely; each is one small test):
3. `consumer.rs:356-359` — `send_datagram`'s spent-stream `Io` arm
3. `consumer.rs:356-359`: `send_datagram`'s spent-stream `Io` arm
(mirror the `spent_session_*` shapes; the spent-`recv` arm is
covered).
4. `consumer.rs:419` — `recv_datagram`'s mid-batch later-frame `Err`
4. `consumer.rs:419`: `recv_datagram`'s mid-batch later-frame `Err`
arm (first frame `Ok`, a later one garbage → the pending `Ok`s
dropped with the session — pin the fail-loud-drops-pending
dropped with the session; pin the fail-loud-drops-pending
semantics).
5. `consumer.rs:453-456` — `AssociateSession::into_inner`'s `Ok`
5. `consumer.rs:453-456`: `AssociateSession::into_inner`'s `Ok`
arm (mirror `into_inner_pairs_the_pipe_with_the_teardown_owner`).
6. `consumer.rs:636-642` — `BindDataPlane`'s `poll_flush`/
6. `consumer.rs:636-642`: `BindDataPlane`'s `poll_flush`/
`poll_shutdown` passthroughs (the BIND door's half-close rides
these — one shutdown test).
7. `consumer.rs:843` — `read_reply2`'s version-mismatch arm (a
foreign producer's non-5 version reply#2 — one unit test on
these; one shutdown test).
7. `consumer.rs:843`: `read_reply2`'s version-mismatch arm (a
foreign producer's non-5 version reply#2; one unit test on
`read_reply2` directly).
8. `producer.rs:560` — the quiet wait's `Err(_)` (I/O error, not
EOF) arm; `producer.rs:568` — the BIND reply#2 Domain-peer
fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`) — one
8. `producer.rs:560` (the quiet wait's `Err(_)`, i.e. an I/O error, not
EOF); `producer.rs:568` (the BIND reply#2 Domain-peer
fallback (`TargetAddr::Domain(_, port)` → `0.0.0.0:port`)): one
factory returning a Domain peer pins it.
9. `egress.rs:72-74, 226-230, 275-279` — the three `Debug` impl
bodies (render once each); `error.rs:113-115` —
9. `egress.rs:72-74, 226-230, 275-279`: the three `Debug` impl
bodies (render once each), plus `error.rs:113-115`:
`SocksEstablishmentReason`'s `Display`. Cosmetic; batch freely.
**Explicitly out of scope** (the review's accepted-uncovered
posture): `rfc1928/client.rs:217-219` (`use_password_auth`'s misuse
arm — review-001-era accepted-uncovered with a translated-error
arm: review-001-era accepted-uncovered with a translated-error
note) and the fork's accepted-uncovered blocks. Do not chase them.
If any arm became unreachable by the code-fix tasks (e.g. the C-1
@@ -78,7 +78,7 @@ through an artificial shape.
## Acceptance Criteria
- [x] The stage's failure exits tested (`ReplyFrameRejected`,
`EgressEnded` both arms, `Io`) — the `send`-failure arm with
`EgressEnded` both arms, `Io`), with the `send`-failure arm using
the drop-witness assertion
- [x] `AssociateSession::split` + `BindSession::split` tested on a
live stream (or the batch-2-door note recorded per arm)
@@ -94,7 +94,7 @@ through an artificial shape.
## References
- docs/reviews/002-protocol-core-implementation-review.md §N-8 (the
attributed line list — the task's source of truth)
attributed line list, the task's source of truth)
- tests/harness.rs (the topology + egress shapes to extend)
- src/producer.rs `egress_factory_called_once_and_dropped_with_the_stage`
(the drop-witness pattern)
@@ -127,7 +127,7 @@ through an artificial shape.
treatment: `udp_egress_send_failure_ends_the_association_and_
drops_the_egress` (a full ASSOCIATE conversation, one datagram in,
the egress refuses it, EOF reaches the client, the stage exits,
and the drop flag flips — teardown-at-birth on the failure path).
and the drop flag flips: teardown-at-birth on the failure path).
The tests ride `FailStream` (a poll-failing stream) and
`OneReplyEgress`/`RelayEndedEgress`/`ParkedEgress` (stage-shaped
egress fakes), all inside the producer test module.
@@ -135,52 +135,52 @@ through an artificial shape.
yields_live_halves_and_survives_the_round_trip` and `bind_split_
yields_live_halves_pre_reply2` exercise `tokio::io::split` on a
live `Socks5Stream<BiStream>` built by a new test helper
(`build_skip_auth_bi_stream` — two duplexes joined via
(`build_skip_auth_bi_stream`: two duplexes joined via
`BiStream::from_joined`, the same shape `open_and_negotiate`
assembles): raw framed bytes pass through both directions on the
borrowed halves, and (BIND) the split leaves the take-guard
untouched — `await_inbound` still reads the reply#2 phase after
untouched; `await_inbound` still reads the reply#2 phase after
the halves are dropped. No "batch-2 door exercises it" note
needed; the tests stand alone.
- **The single-arm residue, test by test:**
`send_datagram_on_a_spent_stream_is_the_io_arm` (the spent-stream
`Io` arm — and the spent-`recv` shape asserted in the same test:
`Io` arm, with the spent-`recv` shape asserted in the same test:
`Ok(None)`, the family's read-side posture),
`recv_datagram_mid_batch_later_frame_err_drops_the_pending_oks`
(first frame `Ok`, a later one garbage → the pending `Ok`s dropped
with the session, the fail-loud-drops-pending semantics pinned),
`associate_into_inner_pairs_the_stream_with_the_teardown_owner`
(the `TakenAssociateInner` Ok arm — the CONNECT
(the `TakenAssociateInner` Ok arm, the CONNECT
`into_inner_pairs_the_pipe_with_the_teardown_owner` shape's
ASSOCIATE mirror),
`bind_data_plane_poll_flush_and_poll_shutdown_pass_through`
(write → `poll_flush` → `poll_shutdown` — the bytes reach the peer
(write → `poll_flush` → `poll_shutdown`: the bytes reach the peer
and the EOF sentinel follows),
`read_reply2_rejects_a_foreign_producers_version` (a non-5
version reply#2 → `UnsupportedSocksVersion(0x04)`, loud),
`bind_quiet_wait_read_error_ends_the_wait_cleanly` (the quiet
wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve`
— a read *error* ends the wait `Ok(())` and the accept future
wait's `Err(_)` arm driven directly on `bind_quiet_wait_and_serve`,
where a read *error* ends the wait `Ok(())` and the accept future
drops with its owned state, the client-gone contract's
error-shaped twin), `bind_domain_peer_falls_back_to_the_
unspecified_address` (a foreign accept handle carrying
`TargetAddr::Domain(_, port)` peer → reply#2 carries
`0.0.0.0:port`, data plane live),
`debug_impls_render_the_handle_surfaces` (the three egress.rs
`Debug` bodies — TargetHandle non-exhaustive, BindTargetHandle
`Debug` bodies: TargetHandle non-exhaustive, BindTargetHandle
names the peer, BindAccept names the bind address),
`establishment_reason_display_renders_the_wire_code` (the
`SocksEstablishmentReason` `Display` — each variant renders as its
`SocksEstablishmentReason` `Display`; each variant renders as its
canonical wire code), `associate_debug_renders_channel_liveness_
and_pending` (the ASSOCIATE + BIND session `Debug` bodies),
`spent_associate_close_reaps_a_no_op` (the `close`-on-spent arm),
and `establisher_records_identity_and_defaults_the_establishment`
extended with the `None`-witness arm.
- **A quiet-wait select-shape discovery (test-only note):**
`tokio::select!` polls branches in random order — a read branch
`tokio::select!` polls branches in random order: a read branch
that errors on its *first* poll can win before the accept branch
is ever polled, dropping the accept future at zero polls (the
owned state never constructed; a drop flag would never fire — the
owned state never constructed; a drop flag would never fire, and the
first cut of the test caught this flake). The test's `FailReadStream`
parks once (with a wake) then errors on the second poll so both
branches are guaranteed started; the assertion then holds
@@ -192,11 +192,11 @@ through an artificial shape.
now `418` (the `inner.as_mut()` else arm after the C-1/D-1
changes); it is covered by the same test that pins the spent-send
arm. `consumer.rs:937` (the spent-bind test's `err().map()`
diagnostic closure) stays uncovered — it only evaluates on `Err`,
diagnostic closure) stays uncovered, since it only evaluates on `Err`,
and the spent shape is `Ok(None)` by design (the C-1 posture);
cosmetic, not chased.
- **Reachability check for the arm-became-unreachable clause**: no
N-8 arm was made unreachable by the three code-fix tasks — every
N-8 arm was made unreachable by the three code-fix tasks; every
named arm still exists at its (shifted) location and is now
tested. The explicit out-of-scope items stayed out of scope:
`rfc1928/client.rs:217-219` remains accepted-uncovered (with its
@@ -204,7 +204,7 @@ through an artificial shape.
blocks.
- **Residual uncovered lines after this task (none new)**: producer
.rs 188-189 (the pump handler's `accept_bi` substrate-failure log
arm — pre-existing, not in N-8's list; untestable without a
arm: pre-existing, not in N-8's list, and untestable without a
broken `Connection` outside alkcall's test surface),
rfc1928/client.rs 217-219 + the fork internals (accepted-
uncovered per review-001/002), wire.rs/mod.rs/target_addr.rs
+27 -27
View File
@@ -1,6 +1,6 @@
---
id: implementation/fix-hygiene-residue
name: "Fix — error-schema declaration + small hygiene residue (review-002 D-3, N-3, N-9)"
name: "Fix: error-schema declaration + small hygiene residue (review-002 D-3, N-3, N-9)"
status: completed
depends_on: []
scope: narrow
@@ -12,20 +12,20 @@ level: implementation
## Description
The small mechanical residue that does not ride either code-fix
task — one doc-vs-code drift with a real fix, two one-line
task: one doc-vs-code drift with a real fix, plus two one-line
postures (`docs/reviews/002-protocol-core-implementation-review.md`):
1. **D-3 (minor) — `error_schemas` is `vec![]` while the module doc
1. **D-3 (minor): `error_schemas` is `vec![]` while the module doc
claims the ADR-049 §4 + ADR-016 contract** (`src/params.rs:94-96`
vs `:117`). The typed vocabulary works behaviorally
(`SocksOpenError::reason` parses `details.reason` off the wire —
(`SocksOpenError::reason` parses `details.reason` off the wire,
tested), but a discovery consumer reading `error_schemas` from
the registry sees no declared failure contract for
`channels/socks5/sub`, and the doc describes a contract the spec
does not carry.
Fix per the review's lean: **declare the `ErrorDefinition`** —
port alktty's (the first protocol crate's precedent,
Fix per the review's lean: **declare the `ErrorDefinition`**,
porting alktty's (the first protocol crate's precedent,
`/workspace/@alkdev/alktty/src/channels.rs:165-208`: the
`channel:open_failed` definition with the `{ reason, message }`
schema, pinned by a test asserting the declaration at
@@ -35,33 +35,33 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
registry's `FORBIDDEN` class). One test mirrors alktty's
declaration pin. If the declare-side turns out to hit a
substrate constraint (the registry rejects the definition, say),
fall back to the honest reword ("empty — the typed contract is
fall back to the honest reword ("empty, since the typed contract is
behavioral via `SocksOpenError::reason`; the `ErrorDefinition`
pass is crate-family-wide, matching alktunnels, pending") and
record why.
2. **N-3 (observation) — `DatagramReader::feed`'s let-else at
2. **N-3 (observation): `DatagramReader::feed`'s let-else at
`src/wire.rs:229-231` is structurally unreachable** (0
executions; the block above always sets `current` before the
let-else when `buf.len() >= 2`, and the loop broke at `:223`
otherwise). Restructure per the review's sketch — match on the
otherwise). Restructure per the review's sketch: match on the
already-known-`Some` state, or re-order to drop the else. Zero
behavior change; the full wire suite stays green unchanged.
3. **N-9 (observation) — `is_mid_frame` is public but consumed
3. **N-9 (observation): `is_mid_frame` is public but consumed
nowhere outside the codec's own tests** (`src/wire.rs:253-260`).
Keep (a batch-2 front-door bridge driving its own reader loop
would use it) — one doc line naming the anticipated consumer
would use it), and add one doc line naming the anticipated consumer
("the doors") so the next dead-surface sweep does not flag it
without this note.
## Acceptance Criteria
- [x] `socks5_open_spec` declares the `channel:open_failed`
`ErrorDefinition` (the alktty-ported schema, adapted) — or the
`ErrorDefinition` (the alktty-ported schema, adapted), or the
doc is reworded to the honest empty posture with the reason
recorded
- [x] A test pins the declaration (mirroring alktty's) — or the
- [x] A test pins the declaration (mirroring alktty's), or the
reworded doc matches the spec exactly
- [x] The `feed` let-else is restructured; zero behavior change
(the wire suite green unchanged)
@@ -73,23 +73,23 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
- docs/reviews/002-protocol-core-implementation-review.md §D-3, §N-3,
§N-9
- alkcall ADR-049 §4 + ADR-016 (the typed error contract —
- alkcall ADR-049 §4 + ADR-016 (the typed error contract:
`/workspace/@alkdev/alkcall/docs/architecture/decisions/`)
- alktty precedent: `/workspace/@alkdev/alktty/src/channels.rs:165-208`
(the `ErrorDefinition` + schema) and `:790` (the pinning test)
- alktunnels sibling: `/workspace/@alkdev/alktunnels/src/params.rs`
(the empty-vec posture — the fallback's precedent)
(the empty-vec posture, the fallback's precedent)
## Notes
- **D-3 — declared, not reworded.** The review's lean (the declare
- **D-3: declared, not reworded.** The review's lean (the declare
side) worked with no substrate constraint: the registry accepted
the definition on the first try. The schema is alktty's
`channel:open_failed` block
(`/workspace/@alkdev/alktty/src/channels.rs:165-208`), adapted:
the `reason` enum carries all five ADR-049 §3 codes
(`dial_failed` / `unknown_resource` / `resource_shortage` /
`handler_error` / `timeout`) — alktty's enum has four because its
`handler_error` / `timeout`); alktty's enum has four because its
establisher has no `resource_shortage` path, while this crate's
declared vocabulary is chosen to match
`SocksEstablishmentReason`'s parse table exactly (the typed view a
@@ -99,12 +99,12 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
establisher could produce any of the five). The `description`
text is rewritten to this crate's establishment story: the
registry gate classes first (`FORBIDDEN` / `INVALID_INPUT` /
`channel:too_many_channels` — the ACL runs before the
`channel:too_many_channels`; the ACL runs before the
establisher, ADR-006), then the establishment vocabulary, then
the no-phantom-channel property. One judgment call worth noting
for the review round: the declared enum is *wider* than the
default producer's behavioral surface (which produces none of the
five today — the dial is in-stream, post-open). That is the
five today, because the dial is in-stream, post-open). That is the
correct direction to over-declare: `error_schemas` discloses the
op's contract shape (any registration of this op, per ADR-049
§4), and the enum is the wire's parse table
@@ -114,10 +114,10 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
Beyond mirroring alktty's declaration pin
(`:780-807`), `open_spec_declares_the_open_failed_error_definition`
asserts each enum value round-trips through
`SocksEstablishmentReason::parse`/`as_str` — the declared schema
`SocksEstablishmentReason::parse`/`as_str`, so the declared schema
and the typed vocabulary cannot drift silently (the exact D-3
drift class, pinned shut).
- **N-3 — restructured to a live-else shape, not the review's
- **N-3: restructured to a live-else shape, not the review's
sketch.** Both of the review's alternates were considered: the
`let Some(&mut (declared, ref mut acc))` match has the same
dead-arm problem (it must fire *after* the header read, which is
@@ -125,13 +125,13 @@ postures (`docs/reviews/002-protocol-core-implementation-review.md`):
sketch inverts the loop's exit order. The landed shape folds the
header read into the let-else's else arm
(`let Some((declared, acc)) = self.current.as_mut() else {
<read header; continue; }`) — every branch is live, the
<read header; continue; }`), where every branch is live, the
"current set above" invariant is structural (the else arm is the
only place `current` is set), and the body is untouched. Zero
behavior change: the full wire suite passes unmodified.
- **N-9 — kept with the note.** One doc paragraph naming the
- **N-9: kept with the note.** One doc paragraph naming the
anticipated batch-2 consumer (a front-door bridge driving its own
reader loop — the doors) and citing the review finding.
reader loop, i.e. the doors) and citing the review finding.
## Summary
@@ -139,17 +139,17 @@ All three residue items closed in one pass (`src/params.rs`,
`src/wire.rs`):
1. **D-3**: `socks5_open_spec` now declares the
`channel:open_failed` `ErrorDefinition` — alktty-ported schema
`channel:open_failed` `ErrorDefinition` (an alktty-ported schema
(`{ reason (enum of the five ADR-049 §3 codes), message }`,
both required), description adapted to this crate's gate classes
+ establishment story. Module doc updated from claim to fact.
Pinned by `open_spec_declares_the_open_failed_error_definition`
(declaration + enum round-trips through
`SocksEstablishmentReason` — schema and typed vocabulary pinned
`SocksEstablishmentReason`, pinning schema and typed vocabulary
drift-free).
2. **N-3**: `DatagramReader::feed`'s structurally-unreachable
let-else restructured into a live-else shape (header read in the
else arm); zero behavior change — the wire suite green unchanged.
else arm); zero behavior change: the wire suite green unchanged.
3. **N-9**: `is_mid_frame` kept; its doc names the anticipated
consumer (the batch-2 front-door bridges) so future dead-surface
sweeps don't flag it.
@@ -1,6 +1,6 @@
---
id: implementation/fix-producer-gate-policy
name: "Fix — producer BIND gate policy + register coverage + stage doc lines (review-002 C-3, D-2, S-1, N-2)"
name: "Fix: producer BIND gate policy + register coverage + stage doc lines (review-002 C-3, D-2, S-1, N-2)"
status: completed
depends_on: []
scope: narrow
@@ -16,32 +16,32 @@ unexercised assembly surface, plus the two doc-line residues that
ride the same files
(`docs/reviews/002-protocol-core-implementation-review.md`):
1. **C-3 (minor) — `serve_socks5_over` derives `can_bind` from
1. **C-3 (minor): `serve_socks5_over` derives `can_bind` from
`accept.is_some()`** (`src/producer.rs:630-648`): factory-wired ⇒
BIND allowed, with no way to say "factory wired but gate closed"
on the substrate-free entry. The pump handler (the channels path)
resolves the gate from the per-call opener identity (ADR-006 —
correct); the substrate-free entry derives it from seam
*presence*. For the batch-2 `local` backend — the documented
consumer of this entry — the local door's BIND authorization has
resolves the gate from the per-call opener identity (ADR-006,
which is correct); the substrate-free entry derives it from seam
*presence*. For the batch-2 `local` backend, which is the documented
consumer of this entry, the local door's BIND authorization has
no seam to plug into except factory presence, which is exactly
the shape that invites "listener configured" ⇒ "listener
permitted" (the conflation ADR-006 exists to prevent).
Fix per the review's sketch: widen the substrate-free entry's
gate to an explicit policy input — `serve_socks5_over_with_gate`
gate to an explicit policy input, `serve_socks5_over_with_gate`
or a builder field `.bind_policy(bool)` defaulting to
`accept.is_some()` for backward-compat with the landed tests.
The pump handler's identity-derived gate is unaffected. One
test: a producer with an accept factory + explicit
`can_bind=false` refuses 0x02.
2. **D-2 (minor) — `Socks5Producer::register` and the builder's
2. **D-2 (minor): `Socks5Producer::register` and the builder's
`egress`/`accept` setters are unexercised public surface** (0%
covered; the harness registers via the free function
`register_socks5_openable` instead, and the builder test drives
only `serve_socks5_over`). The spec'd assembly shape
(`producer.md` §Wiring) is the one assembly path with no test —
(`producer.md` §Wiring) is the one assembly path with no test, so
a signature drift or delegation bug would pass the suite.
Fix: one integration test wiring a full
@@ -49,7 +49,7 @@ ride the same files
through `.register(...)` and serving all three commands over the
harness topology. This is the natural home for C-3's gate test.
3. **S-1 (minor) — the datagram stage's residual hot-`Ok(None)`
3. **S-1 (minor): the datagram stage's residual hot-`Ok(None)`
posture, named as a decision.** The R-1 yield-guard makes the
stage livelock-safe; the residual is a *misbehaving* egress
returning `Ok(None)` at full speed = a yielding busy-wait
@@ -58,7 +58,7 @@ ride the same files
would penalize the compliant path). One doc line on
`run_datagram_stage` naming the residual.
4. **N-2 (observation) — one line in wire.md §UDP ASSOCIATE**: the
4. **N-2 (observation): one line in wire.md §UDP ASSOCIATE.** The
per-datagram `egress.send` is awaited inline (the stage
interleaves sends with reply drains); a slow egress applies
backpressure to the association, not to other channels. The
@@ -87,7 +87,7 @@ ride the same files
- docs/reviews/002-protocol-core-implementation-review.md §C-3, §D-2,
§S-1, §N-2
- docs/architecture/decisions/006-access-control.md (the gate
posture — "listener configured" ≠ "listener permitted")
posture, "listener configured" ≠ "listener permitted")
- docs/architecture/producer.md §Wiring (the spec'd assembly shape)
- docs/architecture/wire.md §UDP ASSOCIATE (the N-2 line's home)
- tasks/implementation/core-producer-bind.md (the landed gate)
@@ -95,18 +95,18 @@ ride the same files
## Notes
- **C-3 shape chosen: the builder field** (`.bind_policy(bool)`),
not a separate `serve_socks5_over_with_gate` entry — the review's
sketch allowed either; a builder field keeps the entry's signature
not a separate `serve_socks5_over_with_gate` entry; the review's
sketch allowed either, but a builder field keeps the entry's signature
untouched (one `serve_socks5_over` shape), rides the existing
builder chain, and the policy is resolved once at `build()`
(`self.bind_policy.unwrap_or(self.accept.is_some())` —
(`self.bind_policy.unwrap_or(self.accept.is_some())`:
`Option<bool>` on the builder, `bool` on the built producer). The
free function `serve_socks5_over` (already gate-explicit) and the
pump handler (identity-derived, ADR-006) are untouched.
- **D-2 vehicle: a new `RegisterMode::ProducerBuilder`** in
`tests/harness.rs` wiring the full builder chain
(`.dial().egress().accept()` + the optional `.bind_policy()`)
through `Socks5Producer::register` — the harness's registration
through `Socks5Producer::register`: the harness's registration
block gains a third arm (same `ChannelCore` +
`register_openable_with_establisher` shape as the others, via the
delegated method). The integration test
@@ -116,7 +116,7 @@ ride the same files
builder-delegated establisher ran). C-3's gate tests live in the
producer unit suite (`builder_bind_policy_false_refuses_bind_
while_connect_associate_serve`, `builder_default_bind_policy_
follows_accept_presence`) — the substrate-free entry is a unit
follows_accept_presence`): the substrate-free entry is a unit
shape; the builder-through-register surface is the integration
one.
- **The `bind_policy(false)` test** pins: 0x02 on BIND with the
@@ -126,7 +126,7 @@ ride the same files
**The default test** pins the backward-compat reading: accept
wired + policy unwired ⇒ BIND serves (factory ran); accept
unwired + policy unwired ⇒ 0x02.
- **S-1/N-2 are doc lines only** (per the review's recommendation —
- **S-1/N-2 are doc lines only** (per the review's recommendation:
posture notes, not backoff/budget code): a residual paragraph on
`run_datagram_stage` (hot-`Ok(None)` = a yielding busy-wait,
bounded by the egress's own correctness, accepted-uncovered by
@@ -136,7 +136,7 @@ ride the same files
## Summary
All four items landed (C-3, D-2, S-1, N-2). `Socks5ProducerBuilder`
gained `.bind_policy(bool)` — the substrate-free entry's explicit
gained `.bind_policy(bool)`, the substrate-free entry's explicit
BIND gate, defaulting to the accept seam's presence (the built
producer resolves it to a plain `bool`); `Socks5Producer::
serve_socks5_over` now consults the policy instead of
@@ -147,12 +147,12 @@ factory un-called while CONNECT/ASSOCIATE serve; the
presence-default both ways). A new harness mode
(`RegisterMode::ProducerBuilder`) plus one integration test wires
the full builder through `.register(...)` and serves all three
commands end to end — the spec'd assembly shape is no longer
commands end to end; the spec'd assembly shape is no longer
unexercised. Doc lines: `run_datagram_stage` names the hot-`Ok(None)`
residual (S-1); wire.md §UDP ASSOCIATE carries the inline-send
backpressure bound (N-2). Verification: `cargo test` 352 green (125
lib + 38 associate + 41 bind + 40 connect + 41 integration + 30
harness + 37 differential + 3 doctests, 2 ignored — the two new
harness + 37 differential + 3 doctests, 2 ignored, since the two new
unit tests and the one new integration test added to the review's
348 baseline), `cargo clippy --all-targets -- -D warnings` clean,
`cargo fmt --check` clean, `cargo doc --no-deps` clean, both wasm
+33 -33
View File
@@ -1,6 +1,6 @@
---
id: implementation/fix-rfc1928-posture
name: "Fix — rfc1928 keep-or-cut pass + honest refusal vocabulary (review-002 N-1, N-4)"
name: "Fix: rfc1928 keep-or-cut pass + honest refusal vocabulary (review-002 N-1, N-4)"
status: completed
depends_on: []
scope: narrow
@@ -11,13 +11,13 @@ level: implementation
## Description
The review-001 keep-or-cut triggers have fired — the producer layer
landed without consuming the anticipated surfaces — and the
The review-001 keep-or-cut triggers have fired (the producer layer
landed without consuming the anticipated surfaces), and the
in-stream refusal vocabulary mislabels policy refusals as bugs.
One posture pass over both
(`docs/reviews/002-protocol-core-implementation-review.md`):
1. **N-1 (minor) — the fork's consumerless machinery.** The
1. **N-1 (minor): the fork's consumerless machinery.** The
producer drives `negotiate_auth` with `&[NoAuthentication]` only;
the seams carry their own error vocabularies; `pump_bidi`
replaced `transfer`. Per the review's disposition:
@@ -27,22 +27,22 @@ One posture pass over both
- `StandardAuthentication` + `allow_no_auth` +
`auth_method_enums!`'s generated enum
(`src/rfc1928/server.rs:448-482`; the macro itself can stay or
go with it — nothing else uses it)
go with it, as nothing else uses it)
- `skip_auth_this_is_not_rfc_compliant`
(`src/rfc1928/server.rs:185-187`; the consumer typestate's
`skip_auth` config covers the in-tree need)
- `CheckResult`'s `Option`/`Result` impls
(`src/rfc1928/server.rs:162-172`; keep `bool` — re-add with a
(`src/rfc1928/server.rs:162-172`; keep `bool`, and re-add with a
typed-rejection design if a door ever wants it)
**Keep with note** (the hand-port-map posture, ADR-013):
- `ConnectError` (constructed nowhere, but the hand-port map's
companion — its reply-code mapping is what a future port
reuses; the module doc already says so)
companion; its reply-code mapping is what a future port
reuses, which the module doc already says so)
- `transfer`/`wait_on_tcp` (differential-suite-consumed; their
semantics stay pinned)
- `SocksServerError::Bug` (now consumed by the producer's
refusal sites — see N-4 for its display)
refusal sites; see N-4 for its display)
The coverage blocks resolve automatically (the accepted-uncovered
posture was exactly these blocks). Update the module docs where
@@ -50,19 +50,19 @@ One posture pass over both
`ConnectError` note, the `auth_method_enums!` doc's "generate a
similar enum" line) and the differential suite's comment that
references `StandardAuthentication`'s method ordering
(`tests/rfc1928-differential.rs:103` — reword to the RFC's
(`tests/rfc1928-differential.rs:103`; reword to the RFC's
ordering rationale, not the cut type).
2. **N-4 (observation) — the task-log refusal vocabulary mislabels
2. **N-4 (observation): the task-log refusal vocabulary mislabels
policy refusals.** Five in-stream refusal sites return errors the
wire never sees (the wire got its RFC-faithful reply already;
these are log/observability signals only), but the vocabulary
lies: the BIND command gate returns
`SocksServerError::AuthenticationRejected` (an ACL refusal is not
"authentication rejected" — `src/producer.rs:230-233`), and four
"authentication rejected"; `src/producer.rs:230-233`), and four
seam refusals return `Bug("... refused in-stream")` (a policy
refusal is not a bug; the display literally says `BUG: ...` —
`src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`).
refusal is not a bug, but the display literally says `BUG: ...` at
`src/producer.rs:262-264`, `302-304`, `483-485`, `541-543`)..
An operator grepping `BUG:` chases ghosts that are
working-as-designed refusals.
@@ -70,17 +70,17 @@ One posture pass over both
(`SocksServerError::Refused(&'static str)`-shaped) for the
in-stream seam refusals and the command gate; use it at the five
refusal sites. The wire bytes are untouched (these variants never
reach the wire — the RFC reply went out first). Alternatively
keep `Bug` and rename its display — but the dedicated variant is
reach the wire, since the RFC reply went out first). Alternatively
keep `Bug` and rename its display, but the dedicated variant is
the honest shape; pick one and record it. Mind the fork's
provenance posture (convention 17): this is an owned-code
extension, so extend the module doc's carried-changes note if the
variant list grows.
Tests: the five refusal sites' existing tests assert the mapped
reply codes (unchanged — the wire is untouched); update any test
reply codes (unchanged, since the wire is untouched); update any test
asserting the *session-error* variant at those sites (the harness's
`Bug` at `tests/harness.rs:387` is its own shape — leave unless it
`Bug` at `tests/harness.rs:387` is its own shape; leave it unless it
wants the new variant); one test pinning the new variant's
display/shape if the vocabulary is table-tested elsewhere.
@@ -89,12 +89,12 @@ display/shape if the vocabulary is table-tested elsewhere.
- [x] `StandardAuthentication`, `allow_no_auth`, the generated
enum, `skip_auth_this_is_not_rfc_compliant`, and
`CheckResult`'s `Option`/`Result` impls are cut; `bool` stays
- [x] `ConnectError`, `transfer`/`wait_on_tcp`, `Bug` kept — each
- [x] `ConnectError`, `transfer`/`wait_on_tcp`, `Bug` kept, each
with its keep-note current (module docs name the posture)
- [x] The differential suite stays green (the cut machinery was not
load-bearing for it; the `:103` comment reworded)
- [x] The five in-stream refusal sites carry the honest vocabulary
(one variant or the renamed display — the choice recorded);
(one variant or the renamed display, with the recorded choice);
the wire bytes are untouched (the reply-code assertions stay
green)
- [x] The fork's provenance notice is extended, not replaced, if the
@@ -109,16 +109,16 @@ display/shape if the vocabulary is table-tested elsewhere.
this closes out; the C-1 misleading-display note)
- docs/architecture/decisions/013-targeted-fork.md (the hand-port
map vs no-speculative-surface distinction)
- docs/architecture/decisions/006-access-control.md (the gate —
- docs/architecture/decisions/006-access-control.md (the gate:
policy refusals are not authentication failures)
- src/rfc1928/LICENSE (the provenance notice — extend, never
- src/rfc1928/LICENSE (the provenance notice; extend, never
replace)
## Notes
- **N-4 shape chosen: the dedicated variant** —
`SocksServerError::Refused(&'static str)`, display `"Refused:
{0}"` — not the `Bug` display rename. The review's sketch favored
- **N-4 shape chosen: the dedicated variant**, namely
`SocksServerError::Refused(&'static str)` with display `"Refused:
{0}"`, not the `Bug` display rename. The review's sketch favored
the dedicated variant ("the honest shape"), and the vocabulary
split is real: `Bug` stays upstream-verbatim (`BUG: {0}`, its
"should be unreachable" contract intact) while policy refusals
@@ -127,13 +127,13 @@ display/shape if the vocabulary is table-tested elsewhere.
`Refused("dial refused in-stream")`,
`Refused("egress refused in-stream")`,
`Refused("bind factory refused in-stream")`,
`Refused("bind accept refused in-stream")` — same
`Refused("bind accept refused in-stream")`, same
`&'static str` payload shape as `Bug`, zero wire impact (the RFC
reply went out before each `Err`).
- **The harness's `Bug` site was migrated too** (the task said
"leave unless it wants the new variant"): `tests/harness.rs`'s
minimal CONNECT producer returned `Bug("dial refused in-stream")`
— the same working-as-designed refusal shape, so it takes the new
minimal CONNECT producer returned `Bug("dial refused in-stream")`,
the same working-as-designed refusal shape, so it takes the new
variant for vocabulary consistency across the log surface. No
test asserts that harness error's variant, so nothing else
moved.
@@ -142,7 +142,7 @@ display/shape if the vocabulary is table-tested elsewhere.
fall-through loop in `to_reply_error_mapping_table` (alongside
`Bug`), and a new test pins the display string and the
fall-through mapping (`refused_variant_display_is_honest_and_
wire_invisible`) — the task's "one test pinning the new variant's
wire_invisible`), covering the task's "one test pinning the new variant's
display/shape".
- **Provenance extended, not replaced** (convention 17): the
`server.rs` module doc gained a "Post-fork owned changes"
@@ -151,7 +151,7 @@ display/shape if the vocabulary is table-tested elsewhere.
post-fork changes" list under the deliberate modifications and a
`last_updated` line. The bundled `src/rfc1928/LICENSE` notice
already carries the "modifications after the fork point" clause
generically — no change needed there.
generically, so no change was needed there.
- **Keep-notes made current**: `stream.rs`'s `ConnectError` note
records that the trigger fired and the disposition is **keep**
(was: "cut it then"); `transfer`/`wait_on_tcp`'s differential-
@@ -159,7 +159,7 @@ display/shape if the vocabulary is table-tested elsewhere.
is the same paragraph (upstream-verbatim, its display untouched).
- **The differential suite's `:103` comment** reworded to the
RFC-ordering rationale (the server picks by its own preference,
more-secure-first) — no cut-type reference remains anywhere.
more-secure-first), and no cut-type reference remains anywhere.
- **The `AuthMethod` trait doc** was updated for the macro cut
(upstream's "use enums for sets" line → a custom selection is a
slice of method structs) and the module doc's extraction-time
@@ -172,8 +172,8 @@ Both items landed. **N-1 (the cut)**: `auth_method_enums!` (the
macro, nothing else used it), `StandardAuthentication` +
`StandardAuthenticationStarted` + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s
`Option`/`Result` impls are gone from `src/rfc1928/server.rs` —
the producer-replaced machinery, per ADR-013's
`Option`/`Result` impls are gone from `src/rfc1928/server.rs`, since
they were the producer-replaced machinery per ADR-013's
no-speculative-surface posture. Kept with notes: `ConnectError`
(hand-port companion, `stream.rs` note updated to record the fired
trigger + keep disposition), `transfer`/`wait_on_tcp`
@@ -1,6 +1,6 @@
---
id: implementation/fix-test-helper-consolidation
name: "Tests — consolidate the duplicated harness shapes (review-002 N-5)"
name: "Tests: consolidate the duplicated harness shapes (review-002 N-5)"
status: completed
depends_on: [implementation/fix-coverage-residue]
scope: narrow
@@ -12,15 +12,15 @@ level: implementation
## Description
The producer's test module re-implements the harness's shapes four
times — ~250 structural-duplicate test lines
times (about ~250 structural-duplicate test lines;
(`docs/reviews/002-protocol-core-implementation-review.md` §N-5).
The cause is legitimate (unit tests in `src/` cannot import
`tests/harness.rs` — separate crates), so the fix is the standard
`tests/harness.rs` are separate crates), so the fix is the standard
shared-module pattern, not a harness import:
- `src/producer.rs:954-987` (`QueueEgress`), `1237-1274`
(`HotNoneEgress`), `1321-1359` (`PureHotNoneEgress`),
`1504-1553` (`Witness` egress) — four `Egress` impls of the same
`1504-1553` (`Witness` egress): four `Egress` impls of the same
queue shape
- `src/producer.rs:792-814` (`greet`/`write_request`/`read_reply`)
vs `tests/harness.rs:876-933` (the same trio)
@@ -34,7 +34,7 @@ keeps the two harness shapes from diverging further (they already
have: the harness's `ChanEgress` and the producer's `QueueEgress`
differ only in the mutex wrapper).
**Not collapsible** (deliberately different — leave them):
**Not collapsible** (deliberately different; leave them):
`HotNoneEgress`/`PureHotNoneEgress` (R-1's two distinct violation
shapes) and the `Witness` egress (carries the drop flag). The
consolidation targets the *byte-near-identical* shapes only:
@@ -54,7 +54,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
`read_reply` trio are single-sourced (the byte-near-identical
shapes)
- [x] `HotNoneEgress`/`PureHotNoneEgress`/`Witness` remain
test-local (deliberately distinct shapes — not collapsed)
test-local (deliberately distinct shapes, not collapsed)
- [x] Zero behavior change: the full suite green with the same test
count (minus any folded duplicates, recorded in the Notes)
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
@@ -77,7 +77,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
"../tests/common/mod.rs"] pub(crate) mod test_common;` plus
`#[cfg(test)] extern crate self as alksocks;`. The `#[path]`
from a nested module (`src/producer.rs`'s tests) does not
resolve — `#[path]` is relative to the containing module's
resolve: `#[path]` is relative to the containing module's
*directory* (`src/producer/tests/` for an inline child), and
`#[path]` rejects non-literal expressions (no
`concat!(env!("CARGO_MANIFEST_DIR"), ...)`), so the only clean
@@ -87,14 +87,14 @@ introduce fresh duplicates of harness shapes, fold them here too.
paths (`alksocks::…`) the integration suite resolves; inside
the lib test crate the alias makes the same paths resolve.
`src/producer.rs` consumes it as `use crate::test_common as
common;` — no `tests/harness.rs` import from `src/`.
common;` with no `tests/harness.rs` import from `src/`.
- **The integration suite reaches the same copy through
`harness`**: `tests/harness.rs` declares `#[path =
"common/mod.rs"] pub mod common;` and re-exports the consumed
subset under the names its callers already use (`echo_egress`
for `echo_egress_factory`, `udp_frame`/`udp_unframe` for
`datagram_frame`/`decode_datagram`,
`witnessing_echo_dial` for `echo_dial_witnessed`) — the
`witnessing_echo_dial` for `echo_dial_witnessed`), and the
per-test `use harness::{…}` surface is unchanged, so the
consumer-* suites needed no edits at all.
- **Single-sourced shapes** (the task's named targets plus the
@@ -105,16 +105,16 @@ introduce fresh duplicates of harness shapes, fold them here too.
wrapper); the producer keeps a one-line `echo_dial(targets)`
adapter since its witness arg is per-test.
- The refusing dial (all five `SocksDialError` variants
reconstructed per call) — three copies → one.
reconstructed per call): three copies → one.
- The queue egress + echo factory: harness `ChanEgress` +
producer's `QueueEgress` (the review's mutex-wrapper-only
divergence) + `echo_egress`/`echo_egress_factory` → common's
`QueueEgress` + `echo_egress_factory` (the mutex-wrapped
shape both had converged on; `next_reply` parks on `recv()` —
shape both had converged on, with `next_reply` parking on `recv()`;
the readiness contract preserved verbatim).
- The refusing egress factory → common's `refusing_egress`
(harness copy; the producer's inline
`Err(EgressError::Unreachable(…))` closures stayed — they are
`Err(EgressError::Unreachable(…))` closures stayed, since they are
one-liners, not the shape).
- The raw RFC 1928 client trio: harness `RawRfcClient`'s
`greet`/`request`/`read_reply` bodies + producer's
@@ -134,24 +134,24 @@ introduce fresh duplicates of harness shapes, fold them here too.
names).
- The parked accept factory: harness-era producer
`parked_accept_factory` + core-integration's `parked_accept` →
common's `parked_accept_factory` (bind addr `127.0.0.1:2121`
— the ECHO_BIND_PORT carrier; core-integration imports it
common's `parked_accept_factory` (bind addr `127.0.0.1:2121`,
the ECHO_BIND_PORT carrier; core-integration imports it
aliased as `parked_accept`).
- **Deliberately not collapsed** (per the task; verified
test-local): producer's `HotNoneEgress` (mutex-yielding hot
`Ok(None)`) and `PureHotNoneEgress` (zero-yield — R-1's two
`Ok(None)`) and `PureHotNoneEgress` (zero-yield, R-1's two
distinct violation shapes), `Witness`/`FailingSendWitness` (the
drop flags), core-integration's `HotNoneEgress` (same-named but
the concurrency-load variant), and the refusing
accept-factory/accept-future pair (typed per error class; used by
the harness and consumer-bind only). Also left: harness's
`echo_accept` + producer's `bind_accept_factory` (the echo-pipe
accept shape) — the producer copy carries the per-test requested-
accept shape; the producer copy carries the per-test requested-
target witness, so the bodies differ beyond the mutex wrapper;
noted for the next touch but out of this task's named scope.
- **Zero behavior change, same test count**: 145 lib / 38 associate
/ 41 bind / 40 connect / 41 integration / 30 harness / 37
differential / 3 doc-tests — byte-identical to the coverage
differential / 3 doc-tests, byte-identical to the coverage
task's post-N-8 population (no folded duplicates; the coverage
task's new tests were all consumer-side unit shapes with no
harness duplicates to fold). Net −307 lines (81 insertions / 388
@@ -169,7 +169,7 @@ introduce fresh duplicates of harness shapes, fold them here too.
## Summary
Consolidated review 002 N-5's duplicated harness shapes into
`tests/common/mod.rs` — the standard `mod common` pattern, mounted
`tests/common/mod.rs`, the standard `mod common` pattern, mounted
from `src/lib.rs` (`#[cfg(test)] #[path] pub(crate) mod test_common`
+ `extern crate self as alksocks`) for the lib test crate and from
`tests/harness.rs` (`#[path = "common/mod.rs"]` + name-preserving
@@ -183,7 +183,7 @@ stream type; the harness's `RawRfcClient` delegates to it), the
datagram frame codec helpers (three copies → one), and the parked
accept factory. Left deliberately distinct: the hot-none egress
variants (R-1's two shapes), the drop-witness egresses, and the
typed accept-refusal factories. Zero behavior change — full suite
typed accept-refusal factories. Zero behavior change (full suite
green with the identical test population (375 + 2 ignored doc), no
tests folded. Verification: `cargo test` + `--all-features` green,
clippy `-D warnings` clean (native + wasm), fmt clean, doc clean,
+24 -24
View File
@@ -1,6 +1,6 @@
---
id: implementation/local-backend-producer
name: Producer-side local backend — the producer's front door (vanilla clients → a locally-served SOCKS5 resource)
name: Producer-side local backend (the producer's front door, vanilla clients → a locally-served SOCKS5 resource)
status: completed
depends_on: [implementation/local-providers]
scope: narrow
@@ -12,48 +12,48 @@ level: implementation
## Description
The producer-side local backend (feature `local`): the same door
shape in the other direction — the producer serves from a real
shape in the other direction; the producer serves from a real
listener, so vanilla clients reach a *locally-served* SOCKS5 resource
(producer.md §Producer-Side Local Backend). This is the shape where
the SOCKS5 service rides a real `TcpListener` instead of (or alongside)
channels: each accepted TCP connection is handed to the landed
substrate-free entry `Socks5Producer::serve_socks5_over` directly —
the RFC conversation runs on the accepted socket, the seams dial
substrate-free entry `Socks5Producer::serve_socks5_over` directly, so
the RFC conversation runs on the accepted socket while the seams dial
locally.
Deliverables:
1. **`src/local/` additions** (feature `local`) — the producer-side
1. **`src/local/` additions** (feature `local`), the producer-side
listener shape:
- A `serve_local`-shaped constructor on the local module (exact
name at implementation; producer.md names no symbol — record
name at implementation; producer.md names no symbol; record
the choice in Notes): takes a `Socks5Producer` (the landed
builder type), a bind address, and the door-policy knobs (the
idle-first-byte timeout + max-concurrent-sessions cap — the
same two knobs the consumer door carries, ADR-014's
idle-first-byte timeout + max-concurrent-sessions cap, the
same two knobs the consumer door carries, per ADR-014's
accept-path defense; an accepted socket is not a channel).
- The accept loop: bind the listener, per accepted connection
spawn `producer.serve_socks5_over(stream)`; per-connection
failures are logged + isolated (the loop continues). The
listener's lifetime is the caller's (a `close`/Drop shape —
listener's lifetime is the caller's (a `close`/Drop shape per
the alktunnels `TunnelListener` teardown posture).
- **This is a front door for the producer side**: the door
surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is
the consumer door's code shape. For the producer-side backend
the NoAuth shape is the primary one (the producer IS the RFC
conversation here — `serve_socks5_over` runs `accept_no_auth`
conversation here, since `serve_socks5_over` runs `accept_no_auth`
in-stream); a cred-bearing variant would wrap
`accept_password_auth` before `serve_socks5_over` — land the
`accept_password_auth` before `serve_socks5_over`. Land the
NoAuth shape; the cred-bearing producer-side door is a
documented follow-up unless a test drives it (record the
decision in Notes).
- The BIND command gate on this path is the builder's
`bind_policy` (the landed `Socks5ProducerBuilder::bind_policy`
— the substrate-free entry's gate); document that the
`bind_policy` (the landed `Socks5ProducerBuilder::bind_policy`,
i.e. the substrate-free entry's gate); document that the
per-caller gate (the pump handler's identity-derived one) does
not apply here — this path has no open-op identity.
not apply here because this path has no open-op identity.
2. **Tests** (`tests/local-backend-producer.rs`): a vanilla RFC 1928
client against the locally-served producer — CONNECT round-trip
client against the locally-served producer: a CONNECT round-trip
through `local::tcp_dial()` seams (the whole stack on real
sockets); BIND through the local backend (the faithful-RFC
variant: real reply#1/reply#2 addresses); the idle-first-byte
@@ -67,11 +67,11 @@ Deliverables:
--check` clean
- [ ] Default wasm checks stay green (backend code is `local`-gated)
- [ ] The backend serves via `Socks5Producer::serve_socks5_over` (the
landed substrate-free entry) — no new protocol-layer code
landed substrate-free entry), with no new protocol-layer code
- [ ] The accept path owns the idle defense + session cap (the two
knobs) — pinned by tests
- [ ] Per-connection failure isolation — pinned by test
- [ ] The `bind_policy` gate is honored on this path — pinned by test
knobs), pinned by tests
- [ ] Per-connection failure isolation, pinned by test
- [ ] The `bind_policy` gate is honored on this path, pinned by test
## References
@@ -87,7 +87,7 @@ Deliverables:
## Notes
- **Symbol choice**: the constructor is `ProducerDoor` (+ `ProducerDoorBuilder`/
`ProducerDoorGuard`) in `src/local/producer_door.rs` — the consumer door's
`ProducerDoorGuard`) in `src/local/producer_door.rs`, named on the consumer door's
sibling name (`FrontDoor`/`UdpFrontDoor`), not `serve_local`: the surface is a
builder + accept-loop object (`serve()` binds, `serve_on(listener)` is the
test/assembly seam over an existing listener), matching the consumer door's
@@ -95,16 +95,16 @@ Deliverables:
- **Door surface decision**: the NoAuth shape landed; the cred-bearing
producer-side door is a documented follow-up (no test drives it). On this
path the producer IS the RFC conversation (`serve_socks5_over` runs
`accept_no_auth` in-stream) — there is no door-side protocol half to
`accept_no_auth` in-stream); there is no door-side protocol half to
re-originate; a cred-bearing variant would wrap `accept_password_auth`
before handing the stream to `serve_socks5_over`.
- **Guard shape** (the alktunnels `TunnelListener` teardown posture):
`serve`/`serve_on` return a `ProducerDoorGuard`; `close()` ends the accept
loop gracefully and awaits the loop task; a Drop aborts it best-effort
(sync, never awaits). In-flight sessions are never force-closed by the
guard — they own their sockets (per-connection isolation cuts both ways).
guard, as they own their sockets (per-connection isolation cuts both ways).
- **BIND gate documentation**: the per-caller gate (the pump handler's
identity-derived one) does not apply here — this path has no open-op
identity-derived one) does not apply here because this path has no open-op
identity; the builder's `bind_policy` is the whole gate, pinned by the
`bind_policy(false)` test (0x02 refusal, CONNECT still serves).
- **No protocol-layer code**: the accept loop rides
@@ -115,7 +115,7 @@ Deliverables:
## Summary
Landed the producer-side local backend (feature `local`): `ProducerDoor`
(`src/local/producer_door.rs`, exported from the crate root) — a real
(`src/local/producer_door.rs`, exported from the crate root): a real
`TcpListener` front door handing every accepted connection to the landed
`Socks5Producer::serve_socks5_over` (NoAuth in-stream RFC conversation;
`local::tcp_dial()` / `udp_egress()` / `bind_accept()` dial locally). The
+37 -37
View File
@@ -1,6 +1,6 @@
---
id: implementation/local-providers
name: Local — the socket-backed seam providers (tcp_dial / udp_egress / bind_accept)
name: Local (the socket-backed seam providers, tcp_dial / udp_egress / bind_accept)
status: completed
depends_on: []
scope: moderate
@@ -12,42 +12,42 @@ level: implementation
## Description
The first batch-2 task: the `local` feature's first-party socket-backed
providers — the three seams' faithful-RFC constructors (ADR-008's
providers, the three seams' faithful-RFC constructors (ADR-008's
local variants, ADR-009's feature gate). These are the assembly-layer
convenience constructors over the landed seam types
(`src/egress.rs`); the protocol layer never sees them.
Deliverables:
1. **`Cargo.toml` feature wiring** — `local = ["tokio/net"]` (the
1. **`Cargo.toml` feature wiring**: `local = ["tokio/net"]` (the
feature's own socket providers; `socket2` only if a provider
actually needs it — the POCs ran tokio-only, so default to NOT
actually needs it, though the POCs ran tokio-only, so default to NOT
adding it). tokio stays `default-features = false` in
`[dependencies]`; the `net` feature rides `local` alone (ADR-009).
Verify the default graph stays net-free (`cargo tree` spot-check)
and the default wasm checks stay green.
2. **`src/local/mod.rs`** (feature `local`) — the provider
2. **`src/local/mod.rs`** (feature `local`): the provider
constructors, named per producer.md §Wiring:
- `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT target;
domain targets resolve here (the ssh `-D` semantic — ADR-012:
domain targets resolve here (the ssh `-D` semantic; per ADR-012
resolution is the provider's job); the connected stream splits
into the `TargetHandle` halves (`Box<dyn AsyncRead + Send + Sync
+ Unpin>` — `TcpStream::into_split` halves are Send+Sync, verify
+ Unpin>`; `TcpStream::into_split` halves are Send+Sync, so verify
the boxing compiles). Dial failures map to `SocksDialError`
faithfully (`ConnectionRefused` for connect-refused,
`NetworkUnreachable`/`HostUnreachable` per `io::ErrorKind`,
`NotAllowed` never synthesized by this provider).
- `udp_egress()` → `EgressFactory`: one real `UdpSocket` per
association (bind `127.0.0.1:0` — the assembly-layer bind
decision, explicit here), per-datagram `send_to`/`recv_from`, no
association (bind `127.0.0.1:0`, the assembly-layer bind
decision, made explicit here), per-datagram `send_to`/`recv_from`, no
flow table; domain destinations resolve at the egress
(`resolve_target`-shaped — port the POC #2 `LocalEgress`
(`resolve_target`-shaped; port the POC #2 `LocalEgress`
helper). **The readiness contract is load-bearing**: `next_reply`
parks on `sock.readable().await` then `try_recv_from`
(`WouldBlock` → `Ok(None)` spurious-wakeup shape; a real async
park — never a hot `Ok(None)`); replies carry *inner* bytes
(`TargetAddr::to_be_bytes() + data` — the sender's address per
the RFC's per-datagram source semantics; framing-once — the
(`WouldBlock` → the `Ok(None)` spurious-wakeup shape; a real async
park, never a hot `Ok(None)`); replies carry *inner* bytes
(`TargetAddr::to_be_bytes() + data`, the sender's address per
the RFC's per-datagram source semantics; framing-once, so the
length prefix is the wire layer's). `send` takes payload only.
Egress failures map to `EgressError` (bind failure → `General`;
resolve failure → `Unreachable`).
@@ -56,14 +56,14 @@ Deliverables:
requested target's port honored when non-zero (FTP active mode),
port 0 → ephemeral; `local_addr()` is reply#1's `bind_addr`; the
accept future owns the listener end-to-end (the quiet wait's
drop IS the client-gone teardown — the listener lives inside the
drop IS the client-gone teardown, and the listener lives inside the
future, never in a separate binding); `accept()`'s peer is
reply#2's `BindTargetHandle.peer` (byte-exact). Factory failures
→ `BindAcceptFactoryError` (bind refused/held port → `Refused`
or `Shortage` per the io error; accept-stage failures →
`BindAcceptError`). Address policy is the caller's: the
constructor takes the bind host (loopback default is the POC's
posture — a wildcard bind is the caller's explicit choice).
posture; a wildcard bind is the caller's explicit choice).
- Module doc: names the ADRs (008/009), the never-binds posture
(the providers ARE the explicit bind decisions), and the seam
contracts each constructor must honor (readiness, ownership,
@@ -72,7 +72,7 @@ Deliverables:
integration file): real sockets (dev-deps already carry full
tokio). Echo TCP server for `tcp_dial` round-trips; a real UDP
echo for the egress (datagram round-trip through the producer
stage — the POC #2 shape); a real BIND accept (dial the listener,
stage, the POC #2 shape); a real BIND accept (dial the listener,
reply#1/reply#2 carry the real addresses); the readiness contract
re-pinned with the real socket egress (park-then-datagram, no
spin); refusal mappings (connect-refused → 0x05 through the
@@ -84,15 +84,15 @@ Deliverables:
- [x] `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt
--check` clean
- [x] Default wasm checks stay green (`cargo check`/`clippy --target
wasm32-unknown-unknown -- -D warnings`) — `local` code is
wasm32-unknown-unknown -- -D warnings`), meaning `local` code is
cfg-gated out of the default build
- [x] Default dependency graph has no `tokio/net`/`mio`/`socket2`
(`cargo tree` spot-check); `local = ["tokio/net"]` wired
- [x] `tcp_dial`/`udp_egress`/`bind_accept` land with the exact
producer.md §Wiring names and the landed seam types
- [x] The egress readiness contract honored (real async park; no hot
`Ok(None)`) — pinned by test
- [x] The accept future owns its listener end-to-end — pinned by a
`Ok(None)`), pinned by test
- [x] The accept future owns its listener end-to-end, pinned by a
drop-witness test (client-gone → listener closed)
- [x] Refusal mappings table-tested against the landed error types'
`to_reply_error()`
@@ -104,7 +104,7 @@ Deliverables:
seams), 007 (BIND variant 1), 012 (resolution at the egress)
- docs/architecture/producer.md §Wiring, §The Three Seams
- POC reference: `/workspace/alksocks-udp-poc/src/egress.rs`
(`LocalEgress` — the readiness shape), `/workspace/alksocks-bind-
(`LocalEgress`, the readiness shape), `/workspace/alksocks-bind-
poc/src/accept.rs` (`local_bind_accept`)
- Landed seams: `src/egress.rs` (the contracts' normative doc
comments)
@@ -114,7 +114,7 @@ Deliverables:
> Filled by the implementation agent (2026-09-25).
- `Cargo.toml`: `local = ["tokio/net"]` (the feature's own socket
providers; no `socket2` — the POCs ran tokio-only and nothing here
providers; no `socket2`, because the POCs ran tokio-only and nothing here
needs it). tokio stays `default-features = false` with the
wasm-clean subset; the `net` feature rides `local` alone
(ADR-009). `cargo tree` spot-checks: the default graph
@@ -122,13 +122,13 @@ Deliverables:
`tokio/net`/`mio`/`socket2` (inverting `-i mio` prints nothing);
with `--features local --target all` the `net` feature appears
rooted at `alksocks feature "local"`, dragging `mio` + `socket2`
in through tokio (the dev-dep `full` also appears in dev builds —
pre-existing, test-only, not the feature's doing).
in through tokio (the dev-dep `full` also appears in dev builds,
pre-existing and test-only, not the feature's doing).
- `src/local/mod.rs` (feature `local`, one module file + a
`tests` submodule per convention 15):
- `tcp_dial()` → `DialFn`: `TcpStream::connect` per CONNECT
target; domain targets resolve here (the ssh `-D` semantic —
ADR-012), iterating the resolved address set (the `-D`
target; domain targets resolve here (the ssh `-D` semantic,
with ADR-012 in play), iterating the resolved address set (the `-D`
connect-multi-address posture: first success wins, the last
failure is the reported error); `into_split` halves boxed as
`Send + Sync + Unpin` (compiles, pinned by the round-trip test).
@@ -138,16 +138,16 @@ Deliverables:
resolution failure → `HostUnreachable`, everything else →
`General`; `NotAllowed` never synthesized.
- `udp_egress()` → `EgressFactory`: one real `UdpSocket` per
association (bind `127.0.0.1:0` — the explicit assembly-layer
association (bind `127.0.0.1:0`, the explicit assembly-layer
bind decision, `LocalEgress::bind`); per-datagram
`send_to`/`recv_from`, no flow table; domain destinations
resolve at the egress (`resolve_target` — the POC #2
resolve at the egress (`resolve_target`, porting the POC #2
`LocalEgress` helper ported). The readiness contract is the
load-bearing shape: `next_reply` parks on `sock.readable().await`
(a real async park on receive readiness — `writable()` is always
(a real async park on receive readiness, since `writable()` is always
ready for UDP and would spin), then `try_recv_from`
(`WouldBlock` → the spurious-wakeup `Ok(None)`); replies carry
*inner* bytes (`TargetAddr::to_be_bytes() + data` — the sender's
*inner* bytes (`TargetAddr::to_be_bytes() + data`, the sender's
address per the RFC's per-datagram source semantics;
framing-once); `send` takes payload only. Bind failure →
`General`; resolve failure → `Unreachable`.
@@ -155,7 +155,7 @@ Deliverables:
`TcpListener` per BIND session (ADR-007 variant 1); the
requested port honored when non-zero, port 0 → ephemeral;
`local_addr()` is reply#1's `bind_addr`; the accept future owns
the listener end-to-end (the listener lives inside the future —
the listener end-to-end (the listener lives inside the future, so
the quiet wait's drop IS the client-gone teardown);
`accept()`'s peer is reply#2's `BindTargetHandle.peer`
(byte-exact). Factory failures → `BindAcceptFactoryError`
@@ -163,7 +163,7 @@ Deliverables:
`AddrNotAvailable` → `Refused`, else `General`);
accept-stage failures → `BindAcceptError` (`General`). Address
policy is the caller's: the constructor takes the bind host
(`IpAddr`-convertible; loopback is the POC's posture — the
(`IpAddr`-convertible; loopback is the POC's posture, and the
wildcard bind is the caller's explicit `0.0.0.0`).
- Module doc names ADR-008/009, the never-binds posture (the
providers ARE the explicit bind decisions), and the four seam
@@ -173,14 +173,14 @@ Deliverables:
`tcp_dial`/`udp_egress`/`bind_accept`/`LocalEgress` re-exports;
the crate doc's "not yet present" paragraph updated (the composed
providers remain pending).
- Tests: 12 unit tests (`src/local/tests.rs` — the TCP echo
- Tests: 12 unit tests (`src/local/tests.rs`: the TCP echo
round-trip incl. domain resolution, connect-refused/unresolvable
mappings, the UDP echo round-trips (inner bytes + sender address,
in-order, empty payload), the park-then-datagram readiness probe,
the BIND accept (real reply#1/reply#2, requested-port honored,
held-port refusal, the drop-closes-the-listener witness)) + 6
integration tests (`tests/local-providers.rs`, feature-gated with
`#![cfg(feature = "local")]` — CONNECT through a real echo target,
`#![cfg(feature = "local")]`: CONNECT through a real echo target,
connect-refused → 0x05 through the producer's reply, ASSOCIATE
through the producer's datagram stage with the real egress, the
readiness contract re-pinned through the stage (park-then-datagram,
@@ -202,17 +202,17 @@ Deliverables:
> Filled on completion (2026-09-25).
Implemented the `local` feature's socket-backed seam providers
(ADR-008's local variants, ADR-009's feature gate) — the three seams'
(ADR-008's local variants, ADR-009's feature gate), the three seams'
faithful-RFC assembly-layer constructors over the landed seam types
(`src/egress.rs`): `tcp_dial()` (real `TcpStream` per CONNECT, domain
resolution at the provider, faithful `SocksDialError` mapping),
`udp_egress()` (one real `UdpSocket` per association bound
`127.0.0.1:0`, per-datagram send/recv, the POC #2 `LocalEgress`
readiness shape — park on `readable()`, `try_recv_from`, inner bytes
readiness shape: park on `readable()`, `try_recv_from`, inner bytes
`to_be_bytes() + data`, framing-once), and `bind_accept(bind_host)`
(a real `TcpListener` per BIND session, requested port honored, the
accept future owning the listener end-to-end, two-stage failure
mapping). The protocol layer never sees them — feature-gated
mapping). The protocol layer never sees them; these are feature-gated
(`local = ["tokio/net"]`), the default graph stays
net/mio/socket2-free (`cargo tree` spot-checked both ways), and the
wasm checks stay green. 18 new tests (12 unit + 6 integration) pin:
+50 -50
View File
@@ -1,6 +1,6 @@
---
id: review-assembly-layer
name: Review — the assembly layer (the batch-2 gate)
name: Review (the assembly layer, the batch-2 gate)
status: completed
depends_on: [implementation/assembly-integration-suite]
scope: narrow
@@ -20,7 +20,7 @@ against the Phase 1 spec set; the remaining work is release polish
Review against the architecture, in this order:
1. **The never-binds posture** (the crate's defining requirement):
the protocol layer still binds nothing — every kernel bind lives
the protocol layer still binds nothing; every kernel bind lives
in `local`-gated code, is an explicit constructor decision, and
the default crate's dependency graph carries no `tokio/net`
(`cargo tree` spot-check). The doors' binds are the doors' own
@@ -38,34 +38,34 @@ Review against the architecture, in this order:
never crosses the channel as a SOCKS password; the RFC UDP-header
codec appears only at the door boundary; FRAG ≠ 0 refused.
4. **The graduation's shape** (ADR-007 variant 2): the composed
accept advertises the real `bound` and the real `peer` — no
accept advertises the real `bound` and the real `peer`, with no
expected-addr stand-in; the POC's superseded workaround is not
resurrected.
5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/
panics outside tests; `thiserror` surfaces; no comments in code
except doc comments + pinned non-obvious-correctness notes;
naming; module structure (feature-gated backend modules never
imported from the protocol/adapter/client modules — convention
imported from the protocol/adapter/client modules, per convention
15's last line); producer/consumer vocabulary.
6. **Feature-gate hygiene** (ADR-009): `local` brings only
`tokio/net` (+ `socket2` if justified); `composed` brings only
alktunnels; the default graph unchanged; the wasm checks green
for default + composed; `local` on wasm fails to build (the
alktty posture — verify the failure is the expected one).
alktty posture; verify the failure is the expected one).
7. **POC-fidelity spot-check**: for two or three of the trickiest
assembly behaviors (the composed egress's LRU eviction + closed-
queue posture, the cred-bearing door's re-origination, the
bind-first correlation), diff the crate's behavior against the
POC crates' findings — the POCs are the empirical record; drift
is a finding (except where an ADR supersedes the POC's shape —
the bind-first graduation is the known one).
POC crates' findings; the POCs are the empirical record, so drift
is a finding (except where an ADR supersedes the POC's shape, of
which the bind-first graduation is the known one).
8. **Docs sync**: the specs match what landed (the integration
task's reconciliation verified; any residue is a finding).
## Summary
Review performed 2026-09-26 against the eight-criteria order; the gate
opens (the crate is feature-complete against the Phase 1 spec set —
opens (the crate is feature-complete against the Phase 1 spec set;
release polish remains). One major correctness finding was corrected
in the review change itself (R-1: the UDP door's in-band bridge lost
the reader-state contract), plus a convention sweep residue (R-2:
@@ -74,113 +74,113 @@ two doc residues (R-3, R-4). No seam-contract, never-binds,
graduation-shape, feature-gate, or POC-fidelity drift was found
beyond these.
**1. The never-binds posture — clean.** The protocol layer
**1. The never-binds posture: clean.** The protocol layer
(`producer.rs`/`consumer.rs`/`wire.rs`/`params.rs`/`egress.rs`/
`error.rs`/`rfc1928/`) names no kernel-socket type (`tokio::net`
appears only in doc prose; zero code references — swept). The default
appears only in doc prose; zero code references, swept). The default
dependency graph carries no `tokio/net`/`mio`/`socket2`
(`cargo tree -e normal -f "{p} [{f}]"`: tokio rides the wasm-clean
subset `bytes,io-util,macros,rt,sync,time`; `net` appears only under
`--features local`, which also brings `mio`+`socket2`; `composed`
adds alktunnels 0.2.0 only and its tokio stays on the clean subset).
Every kernel bind is an explicit constructor decision in `local`-gated
code (`local/mod.rs` — the dial's connect, `LocalEgress::bind`'s
code (`local/mod.rs` with the dial's connect, `LocalEgress::bind`'s
loopback `127.0.0.1:0` (a wildcard bind is the caller's variant, doc
pinned), `local_bind_accept`'s explicit `bind_host` param, the doors'
`TcpListener::bind`/`UdpSocket::bind` behind builder `.bind()`) —
`TcpListener::bind`/`UdpSocket::bind` behind builder `.bind()`), and
the doors' binds are the doors' own (assembly-layer, ADR-009).
**2. Seam-contract compliance (ADR-004) — clean.** *Readiness*: the
**2. Seam-contract compliance (ADR-004): clean.** *Readiness*: the
local egress parks on `sock.readable().await` then `try_recv_from`
(`WouldBlock` → the spurious `Ok(None)`, never hot); the composed
egress parks on the shared queue's `recv().await`, and its
closed-queue condition is a **count-based design** (`live_relays` +
`ever_opened`) — a real improvement over the POC (the POC held its
`ever_opened`), which is a real improvement over the POC (the POC held its
own `inbound_tx` clone, so its queue-`None` arm was unreachable; the
landed egress actually detects every-relay-ended and returns `Err`,
pinned by `closed_queue_yields_err_not_a_hot_none`). *Ownership*:
rides the handle structurally everywhere — the local providers box
rides the handle structurally everywhere: the local providers box
their socket halves into the payloads; the composed accept wraps the
`Send + Unpin`-only alktunnels halves in `SyncHalves`/`OwnedWrite`
(`Mutex<T: Send>` is `Sync`, no unsafe) with the `TunnelListener`
riding INSIDE the write payload (`ForwardedOwner` — the
riding INSIDE the write payload (`ForwardedOwner`; the
disjoint-capture trap is doc-pinned and the whole-struct rebinding is
in place); the egress keeps the session in the table entry (the POC's
insert-time-bug lesson, doc-pinned). No `_` bindings of owning values
(swept). *The accept future owns its resources end-to-end* in both
variants (the listener lives inside the future; the composed future
owns the listener + the oneshot receiver — the client-gone select!
owns the listener + the oneshot receiver; the client-gone select!
drop tears both down). *Framing-once*: the local egress returns
`addr + data` inner bytes; the composed egress returns inner bytes
with its own oversize guard mirroring the wire bound; the tunnel-side
framing is the tunnel codec's (`frame_datagram`), never the RFC
frame's.
**3. Door fidelity (ADR-005/ADR-003) — clean, one finding corrected
**3. Door fidelity (ADR-005/ADR-003): clean, with one finding corrected
(R-1).** The NoAuth door is a literal raw pipe
(`copy_bidirectional`, zero translation — pinned by
(`copy_bidirectional`, zero translation, pinned by
`door-tcp.rs`'s raw-byte probes). The cred-bearing door terminates
RFC 1929 locally through the forked `accept_password_auth` surface,
maps through the static table (the check IS the lookup — one table,
maps through the static table (the check IS the lookup, i.e. one table,
one truth), and re-originates in-band via the token sessions; the
never-pipes-raw-post-auth property is pinned by the re-origination
probe (the vanilla client's command bytes are consumed door-side;
the in-band conversation is fresh). The token is the mapped
identity's id riding the open-op `auth_token` payload (CF-005
precedence (1)) — the password never crosses the channel (pinned);
precedence (1), and the password never crosses the channel (pinned);
the identity never reaches the vanilla client. The RFC UDP-header
codec (`parse_udp_request`/`new_udp_header`) appears only at the
door's boundary (the in-band wire stays `{addr, data}` frames —
door's boundary (the in-band wire stays `{addr, data}` frames,
pinned end to end through a real echo target); FRAG ≠ 0 is refused by
dropping the datagram, the association stays healthy (pinned). The
TCP door's ASSOCIATE arm refuses RFC-faithfully before any session
opens (no phantom data plane). R-1 is the UDP door's in-band decode
state — see the Findings.
state (see the Findings).
**4. The graduation's shape (ADR-007 variant 2) — clean.** The
**4. The graduation's shape (ADR-007 variant 2): clean.** The
composed accept advertises the real OS-chosen `bound` (the open-op
reply's `bound` field via the reply projection; a missing
`bound`/`listen` FAILS the factory — `Shortage` with an explicit
`bound`/`listen` FAILS the factory with `Shortage` and an explicit
no-stand-in message, never an expected-addr stand-in) and the real
forwarded-op `peer` (a `Path` peer maps to the RFC sentinel — the
forwarded-op `peer` (a `Path` peer maps to the RFC sentinel, the
documented addressless case, not a stand-in). The POC's superseded
workaround (reply#1-first + carried `bind_addr` + sentinel peer) is
not resurrected — `tests/composed-accept.rs` pins the real addresses
not resurrected; `tests/composed-accept.rs` pins the real addresses
end to end (`reply#1 advertises the real OS-chosen bound`, `reply#2
carries the forwarded op's real peer`), and the establishment bound
covers only the open-op round trip (the listener open is the
factory's; the accept wait is the teardown-governed quiet-wait phase).
**5. Convention compliance — one residue corrected (R-2).** After
**5. Convention compliance: one residue corrected (R-2).** After
R-2, no `unwrap`/`expect`/`panic!`/`unreachable!` in non-test library
code (the seven batch-2 additions were the builder-required-field
`expect`s and two internal-invariant `expect`s — same class as
`expect`s and two internal-invariant `expect`s, the same class as
batch-1's R-2; all now typed). `thiserror` surfaces throughout (the
new builder errors included); naming/module structure hold — the
new builder errors included); naming/module structure hold; the
feature-gated backend modules (`local/`, `composed/`, `door`) are
never imported from the protocol modules (swept: zero
`crate::local`/`crate::door`/`crate::composed` references in the
protocol layer); producer/consumer vocabulary holds (RFC-facing
"server"/"client" only inside `src/rfc1928/`).
**6. Feature-gate hygiene (ADR-009) — green, all four targets.**
**6. Feature-gate hygiene (ADR-009): green on all four targets.**
`cargo test` (default) green; `cargo test --all-features` green; both
wasm checks clean for default AND `--features composed` (the
composed wasm gate verified live); `local` on wasm fails to build
with exactly the expected shape — `mio` cannot compile on
with exactly the expected shape: `mio` cannot compile on
`wasm32-unknown-unknown` (the alktty posture's build-error
guarantee, verified). `local` brings only `tokio/net` (+ `mio`/
`socket2` transitively); `composed` brings only alktunnels; the
default graph is unchanged from batch 1 (spot-checked per feature).
**7. POC-fidelity spot-check — clean, with the R-1 exception and one
**7. POC-fidelity spot-check: clean, with the R-1 exception and one
documented improvement.**
- *The composed egress's LRU + closed-queue posture*: the lazy
destination→tunnel table, the LRU eviction-before-insert loop, the
touch-on-access ordering, the session-inside-the-entry placement,
and the relay task's shape are all line-faithful to POC #2's
`ComposedEgress` (`/workspace/alksocks-udp-poc/src/egress.rs`).
The one deliberate divergence is the closed-queue detection — the
The one deliberate divergence is the closed-queue detection: the
POC's `recv().await`-`None` arm was structurally unreachable (it
held its own `Sender`); the crate replaces it with the
`live_relays`/`ever_opened` count so the association actually ends
@@ -191,27 +191,27 @@ documented improvement.**
NoAuth variant; the cred-bearing extension is the ADR-005 design,
validated by the integration suite's vanilla-client round trips).
- *The bind-first correlation*: the token→oneshot registry shape
with the forwarded handler owning the data-plane pump (R-02 held —
the handler's inline `pump_bidi` await tracks the plane) matches
with the forwarded handler owning the data-plane pump (R-02 held,
as the handler's inline `pump_bidi` await tracks the plane) matches
the alktunnels 0.2.0 forwarded-op surface; the three-party
half-close deadlock note (the bridge completes on EITHER
direction's EOF) is the pinned non-obvious-correctness comment
done right.
**8. Docs sync — two residues fixed (R-3, R-4).** The integration
**8. Docs sync: two residues fixed (R-3, R-4).** The integration
task's reconciliation was verified: overview.md (module map, feature
gates, dependencies), consumer.md (the front doors, the TokenClient
door-only contract, the BIND take-guard), producer.md §Wiring, and
README.md Current State all match the tree. Residues fixed:
producer.md's local-backend section described the landed shape as a
channel pipe (`copy_bidirectional` onto a `BiStream`) — the landed
channel pipe (`copy_bidirectional` onto a `BiStream`), but the landed
`ProducerDoor` serves the RFC conversation on the accepted socket
directly via `serve_socks5_over` (no channel in the path; rewritten,
including the `bind_policy` gate note the docs omitted);
`associate_with_token`'s doc claimed "the TCP door uses this" — the
`associate_with_token`'s doc claimed "the TCP door uses this", but the
landed TCP door refuses ASSOCIATE before any session (reworded);
`SessionOwner`'s docs claimed it is "shared with the composed
accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
accept", yet the accept uses its own `SyncHalves`/`OwnedWrite` pair
(reworded). No other spec drift.
## Acceptance Criteria
@@ -232,22 +232,22 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
- **R-1 (major → fixed in the review change)**: the UDP front door's
in-band decode (`recv_inband`, `src/door.rs`) created a fresh
`DatagramReader` per call and returned only the first frame of
each feed — a datagram split across channel chunks would have
each feed, so a datagram split across channel chunks would have
restarted the reassembly from scratch per read, and datagrams
batched beyond the first in one chunk were **silently dropped**.
This is exactly POC #2's first-cut bug (ADR-003: "reader state
persists across calls"; `AssociateSession::recv_datagram` owns the
pinned shape) — the door bridge did not carry it, and its own doc
pinned shape), yet the door bridge did not carry it, and its own doc
comment claimed persistence. Unexercised by the suite (every
landed test drives one frame per chunk; the failure needs two
frames coalescing into one read — a burst shape real echo targets
frames coalescing into one read, a burst shape real echo targets
produce under load). Fix: an owning `InbandBridge` (persistent
`DatagramReader` + batched-datagram pending queue, the
`AssociateSession` shape) threaded through the channel→socket
pump, plus the fail-loud mid-frame EOF (never a silent partial
datagram). Pinned by three unit tests on the bridge (batched
frames queue-not-drop; partial-frame state persists across calls
— the test fails against a per-call reader; EOF mid-frame errors
frames queue-not-drop; partial-frame state persists across calls,
so the test fails against a per-call reader; EOF mid-frame errors
loud) and an end-to-end door-udp test with a doubled-echo egress
(`batched_in_band_replies_both_reach_the_vanilla_client`).
- **R-2 (convention, fixed in the review change)**: seven non-test
@@ -265,13 +265,13 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
diagnostic surfaced, `# Panics` → `# Errors`);
`TokenClient::wrap`'s call-connection-taken panic →
`TokenClientError::CallConnectionTaken` (a client is one calling
surface — the door-only contract). Test call sites updated
(`.expect("the door builds")` etc. — test-side, allowed); the new
surface, the door-only contract). Test call sites updated
(`.expect("the door builds")` etc., which is test-side and allowed); the new
error types are re-exported (`lib.rs`).
- **R-3 (doc residue, fixed)**: producer.md's producer-side
local-backend section described the landed shape as a raw channel
pipe (`copy_bidirectional` onto a `BiStream`, "the consumer door's
code shape") — the landed `ProducerDoor` hands each accepted
code shape") since the landed `ProducerDoor` hands each accepted
socket to `serve_socks5_over` (the RFC conversation runs on the
socket; no channel pipe). Reconciled, including the
`bind_policy`-is-this-path's-BIND-gate note and the cred-bearing
@@ -284,7 +284,7 @@ accept" — the accept uses its own `SyncHalves`/`OwnedWrite` pair
- **R-5 (accepted, no fix demanded)**: the end-to-end
`batched_in_band_replies...` test does not deterministically
exercise the batching (the two echo frames may ride separate
channel chunks — timing-dependent); the deterministic pins are the
channel chunks, i.e. timing-dependent); the deterministic pins are the
bridge unit tests (which demonstrably fail against the per-call
reader). The end-to-end test still guards the regression class
through the real stack; the unit tests carry the determinism.
+34 -34
View File
@@ -1,6 +1,6 @@
---
id: review-fix-round-002
name: "Review — the review-002 fix round before batch 2"
name: "Review: the review-002 fix round before batch 2"
status: completed
depends_on: [implementation/fix-bind-reply2-reentry, implementation/fix-producer-gate-policy, implementation/fix-rfc1928-posture, implementation/fix-hygiene-residue, implementation/fix-coverage-residue, implementation/fix-test-helper-consolidation, architecture/adr-014-idle-timeout-posture]
scope: narrow
@@ -11,14 +11,14 @@ level: review
## Description
The closing checkpoint for the review-002 fix round — the gate
The closing checkpoint for the review-002 fix round: the gate
between the fix tasks and batch 2 (the assembly layer: `local`
providers, front doors, `composed` providers). Batch 2 consumes the
session types, the gate entries, and the fork's posture, so a fix
that silently changed a shape (the C-1 take-ownership, the C-3 gate
entry, the N-4 vocabulary) would propagate into every door task.
This is an inline review (it lives in `tasks/` and does not consume
the `docs/reviews/` numbering — the next general review doc is
the `docs/reviews/` numbering, because the next general review doc is
`003-*`).
Verify the fix round against review 002's findings, in this order:
@@ -41,8 +41,8 @@ Verify the fix round against review 002's findings, in this order:
bytes are untouched, and the fork's provenance notice is
extended-not-replaced (convention 17).
5. **D-3 resolved**: the `ErrorDefinition` is declared (or the doc
honestly reworded with the reason recorded) — doc-vs-code drift
gone either way.
honestly reworded with the reason recorded), so doc-vs-code drift
is gone either way.
6. **N-8 coverage**: `cargo llvm-cov` re-run; the named lines
covered or recorded as accepted-uncovered with reasons; no new
load-bearing gaps introduced by the fix round itself.
@@ -50,7 +50,7 @@ Verify the fix round against review 002's findings, in this order:
into both test crates; the deliberately-distinct shapes were not
collapsed; the constructor prologue is single-sourced.
8. **C-2 decided**: ADR-014 exists, decides explicitly, and the
spec lines carry the posture — the batch-2 gate condition.
spec lines carry the posture, which is the batch-2 gate condition.
9. **Cross-cutting**: the full verification sweep (test,
all-features, clippy, fmt, doc, both wasm checks,
publish-dry-run); no convention regressions (no
@@ -66,7 +66,7 @@ when C-2's ADR is accepted and no major finding remains.
## Acceptance Criteria
- [x] Each review-002 finding verified closed (or explicitly
re-dispositioned with a reason) — the per-finding checklist
re-dispositioned with a reason) via the per-finding checklist
above
- [x] `cargo llvm-cov` re-run and attributed (no new load-bearing
gaps)
@@ -74,14 +74,14 @@ when C-2's ADR is accepted and no major finding remains.
`cargo publish --dry-run --allow-dirty`
- [x] Convention compliance re-swept (no unwrap/expect/panics
outside tests; comment posture; wasm-clean)
- [x] ADR-014 accepted — the batch-2 gate condition holds
- [x] ADR-014 accepted, the batch-2 gate condition
- [x] Findings recorded; the gate to batch 2 opens
## References
- docs/reviews/002-protocol-core-implementation-review.md (the
findings this round closes)
- docs/architecture/decisions/014-*.md (the C-2 decision — the
- docs/architecture/decisions/014-*.md (the C-2 decision, the
gate condition)
- tasks/implementation/review-protocol-core.md (the inline-review
shape and findings format this follows)
@@ -95,29 +95,29 @@ when C-2's ADR is accepted and no major finding remains.
1. **C-1 closed correctly.** The take-shape landed as the review's
first sketch: `BindSession::await_inbound` takes the inner stream
before reading reply#2 (`inner.take()` — consumer.rs:792-803); a
before reading reply#2 (`inner.take()`, at consumer.rs:792-803); a
second call hits the `None` arm → the spent shape `Ok(None)`
(consumer.rs:793-795). The returned `BindDataPlane` **owns** the
stream (the `TakenInner` pairing posture; the session stays the
teardown owner) — the re-read is structurally impossible, not
teardown owner), making the re-read structurally impossible, not
just guarded. A failed read restores the inner (the phase is
consumed on success only — the post-failure re-call re-reads
consumed on success only; the post-failure re-call re-reads
against the already-ended session, EOFs, never a live data-plane
re-read). Pinned by
`double_await_inbound_returns_the_spent_shape_and_bytes_survive`
(tests/consumer-bind.rs:497 — the second call `Ok(None)` + the
(tests/consumer-bind.rs:497: the second call `Ok(None)` + the
first plane's echo intact + a fresh session round-trips) and
`reply1_twice_is_a_view_and_the_reader_yields_one_plane`
(tests/consumer-bind.rs:583). **D-1's reader decision is
recorded**: option (a) — the session's `await_inbound` is the one
recorded**: option (a), where the session's `await_inbound` is the one
normative reply#2 reader; `BindReply1::await_inbound` is cut,
`reply1()` is a documented view (`bind_addr` + `raw_mut`) —
and `reply1()` is a documented view (`bind_addr` + `raw_mut`),
recorded in the fix task's Notes and the module docs
(consumer.rs:574-579, 762-765).
2. **C-3 closed.** The substrate-free entry's BIND gate is the
builder's explicit `bind_policy` (`.bind_policy(bool)` —
builder's explicit `bind_policy` (`.bind_policy(bool)`:
producer.rs:736-739, resolved once at `build()`,
`self.bind_policy.unwrap_or(self.accept.is_some())` —
`self.bind_policy.unwrap_or(self.accept.is_some())` per
producer.rs:741-748); "factory wired" no longer implies "BIND
permitted" (the presence reading is only the backward-compat
default). Pinned by
@@ -126,7 +126,7 @@ when C-2's ADR is accepted and no major finding remains.
ASSOCIATE still serve) and
`builder_default_bind_policy_follows_accept_presence` (the
default both ways). The pump handler's identity-derived gate is
byte-identical (producer.rs:181-185) — the identity matrix
byte-identical (producer.rs:181-185), and the identity matrix
(`pump_handler_resolves_the_bind_gate_from_the_opener_identity`)
stayed green untouched.
3. **D-2 closed.** The spec'd assembly shape is exercised end to
@@ -141,12 +141,12 @@ when C-2's ADR is accepted and no major finding remains.
4. **N-1/N-4 landed coherently.** The cut list is gone
(`auth_method_enums!`, `StandardAuthentication` + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, `CheckResult`'s
`Option`/`Result` impls — swept: zero references anywhere); the
`Option`/`Result` impls, all swept: zero references anywhere); the
keep list carries current notes (`ConnectError`'s
stream.rs note records the fired trigger + the keep disposition;
`transfer`/`wait_on_tcp` differential-suite-consumed; `Bug`
upstream-verbatim). The refusal vocabulary is honest at all five
sites — `SocksServerError::Refused(&'static str)` ("Refused: {0}")
sites: `SocksServerError::Refused(&'static str)` ("Refused: {0}")
at the command gate + the four seam refusals (producer.rs
237/269/309/502/560) plus the harness's minimal producer
(tests/harness.rs:294); `Bug`'s upstream-verbatim display intact.
@@ -157,13 +157,13 @@ when C-2's ADR is accepted and no major finding remains.
`last_updated` line; `src/rfc1928/LICENSE`'s generic
post-fork clause needed no change.
5. **D-3 resolved (the declare side).** `socks5_open_spec` declares
the `channel:open_failed` `ErrorDefinition` (params.rs:139-151 —
the `channel:open_failed` `ErrorDefinition` (params.rs:139-151,
the alktty-ported `{reason, message}` schema, the reason enum
exactly `SocksEstablishmentReason`'s parse table), doc-vs-code
drift gone. Pinned by
`open_spec_declares_the_open_failed_error_definition`
(params.rs:256-292), which cross-checks every declared enum value
through the typed parse table — the schema and the typed
through the typed parse table, so the schema and the typed
vocabulary cannot drift.
6. **N-8 coverage re-run and attributed.** `cargo llvm-cov` on the
final tree: **96.30% regions / 96.43% functions / 97.28% lines**
@@ -174,25 +174,25 @@ when C-2's ADR is accepted and no major finding remains.
version pin, the quiet-wait `Err(_)` arm, the Domain-peer
fallback, the `Debug`/`Display` renders). The residual uncovered
lines are exactly the task's recorded set: producer.rs 188-189
(the `accept_bi` substrate-failure log arm — untestable outside
(the `accept_bi` substrate-failure log arm, untestable outside
alkcall's test surface), `rfc1928/client.rs` 217-219 +
internals (the review-001/002 accepted-uncovered posture),
`rfc1928/mod.rs` 136-141 / `target_addr.rs` 132/151/180 (the
fork's pre-existing residue), plus test-module panic-guard
branches (coverage-of-tests noise) and consumer.rs 937 (the
spent-shape diagnostic closure — evaluates only on `Err`, the
spent-shape diagnostic closure, which evaluates only on `Err`, the
spent shape is `Ok(None)` by design; cosmetic, not chased).
No new load-bearing gaps introduced.
7. **N-5/N-6 consolidation verified.** The shared module
(tests/common/mod.rs, 314 lines) compiles into both crates via
the crate-root `#[cfg(test)] #[path]` mount + the
`extern crate self` alias (lib.rs:73-82) and harness's
name-preserving re-exports — the lib tests and the integration
name-preserving re-exports, so the lib tests and the integration
suite build against the same copy; no `tests/harness.rs` import
from `src/`. The deliberately-distinct shapes stayed test-local
(`HotNoneEgress`/`PureHotNoneEgress`/the drop-witness egresses);
the constructor prologue is single-sourced
(`open_and_negotiate`, consumer.rs:85-108 — the
(`open_and_negotiate`, consumer.rs:85-108: the
teardown-at-birth invariant in one place; N-6).
8. **C-2 decided.** ADR-014 exists
(docs/architecture/decisions/014-idle-timeout-posture.md, status
@@ -205,7 +205,7 @@ when C-2's ADR is accepted and no major finding remains.
no-in-band-vocabulary bullet; producer.md §The Open Handler +
the local-backend section + the decision table).
9. **Cross-cutting sweep green** (below); the batch boundary holds
(no `local`/`composed` code exists — the features are declared
(no `local`/`composed` code exists, since the features are declared
empty in Cargo.toml per ADR-009, no `cfg(feature)` in `src/`).
### Findings (this review round)
@@ -213,15 +213,15 @@ when C-2's ADR is accepted and no major finding remains.
No correctness, security, or coverage findings. One minor doc
residue, **fixed in this review change** (the `F-1` treatment):
- **F-1 [minor] — review-numbering collision**: three code
- **F-1 [minor]: review-numbering collision.** Three code
comments/docs and two sites in the review doc cited
"review 002 R-1/R-2/R-4", but those R-findings belong to the
batch-1 gate review (`tasks/implementation/review-protocol-core.md`
— the unnumbered inline review); "review 002" now unambiguously
batch-1 gate review (`tasks/implementation/review-protocol-core.md`,
the unnumbered inline review); "review 002" now unambiguously
means the C/S/D/N general review this task gates. An operator (or
a later sweep) following "review 002 R-1" would land in the wrong
document. Fixed at all six sites (producer.rs ×2, consumer.rs,
egress.rs, the review doc ×2) — reworded to "the gate review's
egress.rs, the review doc ×2) with a rewording to "the gate review's
R-N (tasks/implementation/review-protocol-core.md)". Zero code
impact; the review doc's remaining in-body `R-1`/`R-2` mentions
ride the references section, which already names the task file.
@@ -241,13 +241,13 @@ residue, **fixed in this review change** (the `F-1` treatment):
- `cargo check` + `clippy -D warnings` (wasm32): clean.
- `cargo publish --dry-run --allow-dirty`: completes.
- `cargo llvm-cov`: 96.30% regions / 96.43% functions / 97.28%
lines (5142 covered / 140 missed) — the per-module table:
lines (5142 covered / 140 missed); the per-module table:
consumer 98.60% lines, egress 99.67%, error 100%, params 100%,
producer 95.70%, wire 96.58%, rfc1928 96.53–99.39% (the
accepted-uncovered fork blocks ride client.rs's 96.53%).
- **Convention compliance re-swept**: every `unwrap`/`expect`/
`panic!`/`todo!`/`unimplemented!` hit across all of `src/`
(eleven files) mechanically attributed — all inside `#[cfg(test)]`
(eleven files) mechanically attributed; all are inside `#[cfg(test)]`
regions; no panics in non-test code; comment posture held (doc
comments + pinned correctness notes only); no `cfg(feature)`
outside Cargo.toml declarations.
@@ -260,7 +260,7 @@ the full sweep is green; the fix-round's shapes (the take-guard, the
`bind_policy` gate, the `Refused` vocabulary, the declared
`ErrorDefinition`) are the shapes batch 2's door tasks consume.
The next general review doc is `003-*` (this checkpoint was an
inline review in `tasks/` — it consumed none of the
inline review in `tasks/`, so it consumed none of the
`docs/reviews/` numbering).
## Summary
+49 -49
View File
@@ -1,6 +1,6 @@
---
id: review-protocol-core
name: Review — the protocol core before any assembly-layer work
name: Review (the protocol core before any assembly-layer work)
status: completed
depends_on: [implementation/core-integration-suite]
scope: narrow
@@ -13,8 +13,8 @@ level: review
Review checkpoint before batch 2 (the assembly layer: `local`
providers, front doors, `composed` providers). Everything in batch 2
consumes the seams and the session types, so a core defect — a seam
contract drift, a teardown hole, a wasm regression — would propagate
consumes the seams and the session types, so a core defect (a seam
contract drift, a teardown hole, a wasm regression) would propagate
into every downstream task. This review is the gate between the core
group and the assembly layer, and the point where batch 2 gets
decomposed (its task shapes may adjust to what the core actually
@@ -23,8 +23,8 @@ landed).
Review against the architecture, in this order:
1. **Seam contracts** (ADR-004, producer.md §The Three Seams): the
readiness contract (`next_reply` `Pending` when nothing is ready —
the contract test exists and pins it), ownership-rides-the-handle
readiness contract (`next_reply` `Pending` when nothing is ready,
with the contract test in place pinning it), ownership-rides-the-handle
(no `_` bindings of owning values; handles own their payloads
structurally), the accept future owning its resources end-to-end
(the quiet-wait drop IS the client-gone teardown), framing-once
@@ -37,9 +37,9 @@ Review against the architecture, in this order:
3. **Access control** (ADR-006): the open gate (`socks5:open` at the
registry), the command gate (`socks5:bind` at BIND dispatch),
two grants never implied; refusals RFC-faithful everywhere
(convention 2 — never collapsed into generic errors).
(convention 2, never collapsed into generic errors).
4. **Teardown/lifecycle** (wire.md §Sentinels, alkcall ADR-050):
`pump_bidi` awaited inline (R-02 — no spawn-and-forget); the
`pump_bidi` awaited inline (R-02, no spawn-and-forget); the
teardown matrix tests actually pin the semantics; no
teardown-at-birth paths (the third-witness pattern held).
5. **Convention compliance** (AGENTS.md): no `unwrap`/`expect`/
@@ -54,14 +54,14 @@ Review against the architecture, in this order:
7. **POC-fidelity spot-check**: for two or three of the trickiest
behaviors (the readiness contract, the quiet-wait two-exit
semantics, the split-datagram reader persistence), diff the
crate's behavior against the POC crates' findings — the POCs are
crate's behavior against the POC crates' findings, since the POCs are
the empirical record; drift is a finding.
8. **Docs sync**: the specs match what landed (the integration
task's reconciliation verified; any residue is a finding).
## Notes
> Filled by the review agent — see the Summary and the Findings.
> Filled by the review agent: see the Summary and the Findings.
## Summary
@@ -71,16 +71,16 @@ gate opens. Two findings were corrected in the review change itself
three minor residues (R-3..R-5, fixed in the same change); no
seam-contract, wire-fidelity, ACL, teardown, wasm, or POC-fidelity
drift was found. One prior-session reconciliation (the bind-only
identity row) was independently verified as correct — see the Notes.
identity row) was independently verified as correct (see the Notes).
**1. Seam contracts (ADR-004, producer.md §The Three Seams) — clean,
**1. Seam contracts (ADR-004, producer.md §The Three Seams): clean,
one finding corrected (R-1).** The readiness contract is documented
on the trait (`egress.rs:174-185`), table-tested, and pinned
behaviorally twice (the producer unit test's mutex-guarded hot
egress + the integration suite's hot-`Ok(None)` under concurrent
load). Ownership rides the handle structurally (`TargetHandle`/
`BindTargetHandle` payloads own their halves; no `_` bindings of
owning values anywhere in the crate — swept); the accept future owns
owning values anywhere in the crate, swept clean); the accept future owns
its resources end-to-end (`BindAccept::new` boxes it; the quiet
wait's `select!`-pinned drop is the client-gone teardown, pinned by
the drop-flag tests in `src/producer.rs` and `tests/core-
@@ -90,16 +90,16 @@ the only prefixers; `frame == frame_inner` byte-identity is
table-tested). R-1 is the readiness contract's own tolerance: see
the Findings.
**2. Wire fidelity — clean.** The datagram frame matches wire.md §The
**2. Wire fidelity: clean.** The datagram frame matches wire.md §The
Data Plane byte-for-byte: `[len: u16 BE][addr + data]`, the joint
bound (`frame`/`frame_inner` reject > 65535 with the exact per-form
maxima pinned: 65528 v4 / 65516 v6 / 65276 domain), F-2 at payload
level (empty datagram round-trips end to end, never EOF; `len == 0`
inner is `EmptyFrame` — unreachable from a compliant peer), RSV/FRAG
inner is `EmptyFrame` and unreachable from a compliant peer), RSV/FRAG
absent from the in-band codec. The phase model holds: no mode byte;
post-reply garbage is refused structurally in all three commands
(CONNECT = pure pipe, ASSOCIATE = garbage ATYP fails loud, BIND =
the quiet wait's one-byte read ends the session with no reply#2) —
the quiet wait's one-byte read ends the session with no reply#2),
each pinned by test. Sentinels verified: ASSOCIATE's `127.0.0.1:0`
(asserted in the integration suite + the raw probes), BIND's
variant-3 zeros (`0.0.0.0:0` reply#1 + reply#2, pinned);
@@ -107,22 +107,22 @@ association lifetime = channel lifetime (EOF rows in the teardown
matrix). The BAST document's joint-constraint note matches the
codec.
**3. Access control (ADR-006) — clean.** The open gate is
**3. Access control (ADR-006): clean.** The open gate is
`required_scopes = ["socks5:open"]` on the single op spec
(`socks5_open_spec`); the command gate resolves `socks5:bind` from
the per-call opener identity at BIND dispatch and precedes the
accept seam (a scope-less BIND never mints a listener — pinned).
accept seam (a scope-less BIND never mints a listener, which is pinned).
Both refusals are RFC-faithful (0x02 at the command gate; the
registry's `FORBIDDEN` at the open gate); the mapping tables
(dial/egress/accept/factory errors → reply codes) are table-tested
and route through the forked `ReplyError` — nothing collapsed into
and route through the forked `ReplyError`, with nothing collapsed into
generic errors. The integration suite's identity matrix pins all
three rows end to end with the CF-006 witness asserted.
**4. Teardown/lifecycle — clean.** `pump_bidi` is awaited inline in
**4. Teardown/lifecycle: clean.** `pump_bidi` is awaited inline in
all three data planes (CONNECT, BIND post-reply#2) inside the
handler task; the `JoinHandle` tracks the data-plane lifetime (R-02
held — no spawn-and-forget). The teardown matrix tests pin the real
held, so no spawn-and-forget). The teardown matrix tests pin the real
semantics (half-close / EOF / client-gone-with-parked-accept, each
followed by fresh sessions of every command). No teardown-at-birth
paths: the egress-drop witness test flips the flag on stage exit,
@@ -130,7 +130,7 @@ the accept-drop witness flips on the client-gone branch; the
consumer sessions reap on `close`/`shutdown`/`Drop` with the leak
probes asserting no phantom channels on either side.
**5. Convention compliance — one finding corrected (R-2).** No
**5. Convention compliance: one finding corrected (R-2).** No
`unwrap`/`expect`/`panic!`/`unreachable!` in non-test library code
after R-2 (swept: `src/` non-test regions are clean; the forked
core's panic translations were re-verified). `thiserror` surfaces
@@ -140,7 +140,7 @@ re-exports) hold; producer/consumer vocabulary holds (RFC-facing
home). Cargo.toml's two inline policy comments are the
extraction-review F-3-accepted posture (carried, no fix demanded).
**6. Wasm-clean invariant — green, with one accepted note (R-5).**
**6. Wasm-clean invariant: green, with one accepted note (R-5).**
Both wasm checks clean (`cargo check`/`clippy --target
wasm32-unknown-unknown -- -D warnings`, from scratch); no
`net`-shaped feature (the two declared features are empty markers);
@@ -149,39 +149,39 @@ tokio on the wasm-clean subset with `default-features = false` (no
R-5: see the Findings (a transitive `getrandom` carrier outside
this crate's control).
**7. POC-fidelity spot-check — clean, one finding corrected (R-1).**
**7. POC-fidelity spot-check: clean, one finding corrected (R-1).**
- *Readiness contract*: the crate's stage matches the POC's
`select!`-driven shape (channel reads ∥ reply drains, `Ok(None)`
tolerated as a spurious wakeup); the POC's LocalEgress/
ComposedEgress both park properly (`readable()` / queue
`recv().await`) and the ComposedEgress's closed-queue arm returns
`Err` rather than a hot `None` — exactly the posture the crate
`Err` rather than a hot `None`, which is exactly the posture the crate
inherited. The drift the spot-check surfaced is the *zero-yield*
bound of the contract (R-1) — a pure `async { Ok(None) }` egress
bound of the contract (R-1): a pure `async { Ok(None) }` egress
livelocked the landed stage on a current-thread runtime
(empirically reproduced; the POC's findings named the biased-select
and hot-None hazards, the no-yield variant is the same class one
and hot-None hazards; the no-yield variant is the same class one
step further). Fixed in the review change.
- *Quiet-wait two-exit semantics*: the landed `select!` is
single-shot with exactly the two exits (accept / EOF-garbage-error),
not a loop — matches the POC's `never_loop` finding; the parked-
not a loop, which matches the POC's `never_loop` finding; the parked-
accept probes make both exits deterministic; reply#1/reply#2 carry
the factory's `bind_addr`/peer byte-exactly on the provider
variant and the sentinels on variant 3.
- *Split-datagram reader persistence*: `AssociateSession::recv_
datagram`'s reader + pending queue persist across calls (pinned by
the 2-byte-prefix chunk test and the consumer-associate suite) —
matches ADR-003's re-read of the POC finding.
the 2-byte-prefix chunk test and the consumer-associate suite),
matching ADR-003's re-read of the POC finding.
**8. Docs sync — clean, three residues fixed in the review change.**
**8. Docs sync: clean, with three residues fixed in the review change.**
The integration task's reconciliation was verified against ADR-006
§Consequences, producer.md §Scope Gating, and the landed spec/tests
(see the Notes). Residues found and fixed: `src/egress.rs`'s module
doc still said the readiness tests "land with the producer-associate
task" (that task is landed — reworded to point at where they live);
task" (that task is landed; reworded to point at where they live);
`src/producer.rs`'s establisher doc said "in this task's scope"
(same class — reworded); producer.md §Wiring's sketch showed
`producer.register(&mut channel_core)` — the landed signature takes
(same class, reworded); producer.md §Wiring's sketch showed
`producer.register(&mut channel_core)`, while the landed signature takes
`(&ChannelCore, &OperationRegistry, AuthContext, Option<IdentityWitness>)`
and also carries the establisher (corrected). The rest of the specs
(wire.md, consumer.md §Wiring, ADR-006, rfc1928.md, README Current
@@ -190,7 +190,7 @@ State, lib.rs crate docs) match the tree.
## Acceptance Criteria
- [x] Seam contracts hold (readiness / ownership / accept-future /
framing-once) — with R-1 corrected
framing-once), with R-1 corrected
- [x] Wire fidelity: the frame, phase model, sentinels, lifetimes
byte-checked against wire.md/bast.md
- [x] Access control: both gates + never-implied grants pinned, the
@@ -201,7 +201,7 @@ State, lib.rs crate docs) match the tree.
after R-2; thiserror; naming; module structure; vocabulary
- [x] Both wasm checks green (from clean), no net-shaped feature,
tokio on the wasm-clean subset
- [x] POC-fidelity spot-check of the three trickiest behaviors —
- [x] POC-fidelity spot-check of the three trickiest behaviors:
the zero-yield drift (R-1) found and fixed
- [x] Docs sync: specs match the tree; residues fixed in the same
change
@@ -209,11 +209,11 @@ State, lib.rs crate docs) match the tree.
### Findings
- **R-1 (major → fixed in the review change)**: the datagram stage's
- **R-1 (major, fixed in the review change)**: the datagram stage's
`Ok(None)` tolerance (re-park) is safe against a hot egress only
while its futures yield at least one poll boundary. A *zero-yield*
egress — `next_reply` = a pure `async { Ok(None) }`, no await at
all — wins every `select!` poll forever: the channel branch and
egress (`next_reply` = a pure `async { Ok(None) }`, no await at
all) wins every `select!` poll forever: the channel branch and
every other task on a current-thread scheduler starve (empirically
reproduced: the echo never arrives; the multi-thread runtime
survives, so the landed mutex-guarded unit test masked the hazard
@@ -222,23 +222,23 @@ State, lib.rs crate docs) match the tree.
handling had one gap. Fix: the stage's `Ok(None)` handler now
`yield_now()`s before re-parking (`src/producer.rs`
`run_datagram_stage`), plus a pinning unit test with a pure
zero-yield egress (the strictest violator — hangs without the
zero-yield egress (the strictest violator, which hangs without the
yield) alongside the existing hot-None test. ADR-003's contract
prose and `egress.rs`'s trait doc stay as written (the contract
is on the egress; the stage now additionally defends itself).
- **R-2 (convention, fixed in the review change)**: `Socks5Channel
Session::into_inner` / `AssociateSession::into_inner` ended in
`inner.expect("into_inner on a spent session")` — the only
`inner.expect("into_inner on a spent session")`, the only
non-test `expect` in `src/`. Reachability is narrow (a spent
session's inner is gone only after close/shutdown/Drop, and no
public API constructs a spent session), but convention 2's rule is
the rule: the shape is now `Result<TakenInner, SpentSessionError>`
/ `Result<TakenAssociateInner, SpentSessionError>` with a
zero-carrier error type (`SpentSessionError` — a spent session's
zero-carrier error type (`SpentSessionError`; a spent session's
manager entry is always already reaped, so dropping it loses
nothing; the large-Err/unit-Err lints shaped the choice), pinned
by two spent-session unit tests. Test call sites updated
(`.expect("the session is live")` — test-side, allowed).
(`.expect("the session is live")`, which is test-side and allowed).
- **R-3 (doc residue, fixed)**: `src/egress.rs` module docs + the
`src/producer.rs` establisher doc carried task-time phrasings
("lands with the producer-associate task" / "this task's scope")
@@ -252,7 +252,7 @@ State, lib.rs crate docs) match the tree.
- **R-5 (doc residue, fixed)**: the
`bind_quiet_wait_is_not_under_the_establishment_bound` test doc
claimed the substrate-free probe exercises "the alkcall 10s
default bound" — it cannot (the substrate-free shape has no
default bound", which it cannot (the substrate-free shape has no
alkcall bound; the over-channels wait-outlives-establishment
behavior is the harness's pin by construction). Reworded to say
what the test actually pins (the wait is teardown-governed, no
@@ -286,13 +286,13 @@ State, lib.rs crate docs) match the tree.
- The prior integration session's Notes/Summary were read and
cross-checked against the specs and the tree. The one
reconciliation it flagged — the task file's identity-matrix row 2
reconciliation it flagged (the task file's identity-matrix row 2
expecting a bind-only identity to serve BIND, vs ADR-006's landed
`required_scopes = ["socks5:open"]` unconditionally — is **correct
`required_scopes = ["socks5:open"]` unconditionally) is **correct
as landed**: the op spec (the single op, all three commands, ADR-
001) carries the open gate unconditionally; a bind-only identity
therefore fails closed for every command (FORBIDDEN at the
registry, the establisher never runs, no phantom channel — pinned
registry, the establisher never runs, and no phantom channel is pinned
producer- and consumer-side in
`identity_matrix_bind_only_fails_closed_at_the_open_gate`), and a
BIND-capable deployment grants both scopes explicitly. The suite
@@ -300,14 +300,14 @@ State, lib.rs crate docs) match the tree.
("never implied, in either direction"); the task file's Notes
record the reasoning. No residue: the drift was a Phase-0-era task
phrasing, not a spec-vs-code contradiction. The user's flag was
well-placed — this review treats the reconciliation as verified
well-placed; this review treats the reconciliation as verified
rather than open.
- The self-reported verification claims reproduce (the landed suite
was green at review start; this review's fixes re-ran the full
sweep — see below).
sweep (see below).
- Post-review verification (the review change included): `cargo
test` 348 green (121 lib + 38 connect + 39 associate + 40 bind +
40 integration + 30 differential + 37 rfc1928 + 3 doctests — the
40 integration + 30 differential + 37 rfc1928 + 3 doctests, being the
two R-1/R-2 additions and the four R-2 call-site updates
included), `cargo test --all-features` green, `cargo clippy
--all-targets -- -D warnings` clean, `cargo fmt --check` clean,
@@ -1,6 +1,6 @@
---
id: review-vendor-extraction
name: Review — the vendor extraction before dependent layers
name: Review (the vendor extraction before dependent layers)
status: completed
depends_on: [implementation/vendor-differential-tests]
scope: narrow
@@ -13,8 +13,8 @@ level: review
Review checkpoint before any layer builds on the vendor. Everything
else in the crate (params, wire, producer, consumer, doors, backends)
consumes the vendored typestate/codecs, so extraction errors — a
behavior drift vs upstream, a missed cut, a wasm regression — would
consumes the vendored typestate/codecs, so extraction errors (a
behavior drift vs upstream, a missed cut, a wasm regression) would
propagate into every downstream task. This review is the gate between
the vendor group and the rest of the decomposition.
@@ -23,7 +23,7 @@ Review against the architecture, in this order:
1. **Extraction completeness vs the cut table** (vendor.md): every
"What is Extracted" row is present; every "What is NOT Extracted"
row is absent. Diff `src/vendor/` against
`/workspace/fast-socks5/src/` file-by-file — the diff must contain
`/workspace/fast-socks5/src/` file-by-file, and the diff must contain
exactly: the extraction cut (deletions per the cut table), the two
carried changes (`new_reply` publication; the BIND surface being
present-and-public), the extraction-time adjustments (anyhow/log
@@ -48,8 +48,8 @@ Review against the architecture, in this order:
5. **License/provenance** (ADR-011): the vendor point (v1.0.0) noted
in-tree; the MIT notice present and referenced.
6. **Docs sync**: `vendor.md`'s extraction table and cut table match
what landed (if the implementer discovered a boundary correction —
e.g. a transitive dependency the cut table missed — the doc was
what landed (if the implementer discovered a boundary correction,
e.g. a transitive dependency the cut table missed, the doc was
updated in the same change, not silently diverged).
## Notes
@@ -64,13 +64,13 @@ itself (vendor `mod.rs` + `vendor.md`, same commit); no other
divergence, cut, or drift was found in the full file-by-file diff vs
`/workspace/fast-socks5` v1.0.0.
**1. Extraction completeness vs the cut table** — every "What is
**1. Extraction completeness vs the cut table**: every "What is
Extracted" row is present; every "What is NOT Extracted" row is
absent (swept by name for `Socks5Datagram`/`connect*`/`run_*`/
`udp_bind_random_port`/`handle_udp*`/`transfer_udp`/`Socks5Server`/
`Socks5Socket`/`Incoming`/`DnsResolveHelper`/`Config<A>`/
`Authentication`/`authenticate_callback`/auth example impls/`socks4`/
`resolve_dns`/`tcp_connect*` — the only hit is the preserved verbatim
`resolve_dns`/`tcp_connect*`, with the only hit being the preserved verbatim
error string in `target_addr.rs` referencing `resolve_dns`, a
documented fidelity keep). Every hunk of all five file diffs
(`lib.rs`→`mod.rs`, `server.rs`, `client.rs`, `util/stream.rs`,
@@ -86,7 +86,7 @@ region is mechanical. No unexplained divergence.
wasm32-unknown-unknown` and `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` both clean; no `net`-shaped
feature; tokio on the wasm-clean subset (`rt`, `sync`, `io-util`,
`macros`, `time` — no `full`) with `default-features = false`; deps
`macros`, `time`, with no `full`) with `default-features = false`; deps
are exactly tokio/tracing/thiserror (dev: full tokio only).
**3. Convention compliance**: no `unwrap`/`expect`/`unreachable!`/
@@ -95,18 +95,18 @@ are exactly tokio/tracing/thiserror (dev: full tokio only).
(one module per file, upstream names preserved, `lib.rs` re-exports
`vendor`); `#![forbid(unsafe_code)]` present. `Cargo.toml`'s two
inline comments are the sole code-adjacent comment in non-doc
position — carried from the scaffolding task's declared posture
position, carried from the scaffolding task's declared posture
(ADR-009 feature-layout note); no fix demanded.
**4. RFC fidelity spot-check** (independent): fetched RFC 1928 and
verified against the pinned tests — the reply form (§6: VER/REP/RSV/
verified against the pinned tests: the reply form (§6: VER/REP/RSV/
ATYP/BND.ADDR/BND.PORT → 10 bytes v4, 22 bytes v6, RSV=X'00', codes
`0x00`–`0x08`, `0x09`–`0xFF` unassigned), the method-selection
preference order (§3 + upstream's outer-loop contract), the UDP
ASSOCIATE zero fallback (§7: "MUST use a port number and address of
all zeros"), the 255-byte domain cap (§5: 1-byte length, no NUL), and
the FRAG parser/caller split (§7: MUST drop FRAG≠0 — refusal is the
caller's, passthrough the parser's). All pinned bytes correct. The
the FRAG parser/caller split (§7: MUST drop FRAG≠0, with the refusal being the
caller's and passthrough the parser's). All pinned bytes correct. The
differential suite's 32 tests pass; its upstream file+line citations
were spot-checked against the reference checkout and are accurate.
@@ -125,7 +125,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
## Acceptance Criteria
- [x] The file-by-file diff vs upstream contains only
cut-table/carried-change/adjustment/provenance changes — no
cut-table/carried-change/adjustment/provenance changes, with no
unexplained divergence
- [x] Both wasm checks pass (`cargo check` and
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`)
@@ -140,9 +140,9 @@ Config-Default/`write_all` notes; `last_updated` bumped).
### Findings
- **F-1 (critical → resolved in the review change)**:
- **F-1 (critical, resolved in the review change)**:
`ReplyError::from_u8` kept upstream's `unreachable!("ReplyError
code unsupported.")` arm (`src/vendor/mod.rs:240` pre-fix) —
code unsupported.")` arm (`src/vendor/mod.rs:240` pre-fix), and it is
wire-reachable: `Socks5Stream::read_request_reply` feeds the raw
reply byte from the wire into it (`src/vendor/client.rs:340`), and
RFC 1928 §6 marks `0x09`–`0xFF` unassigned, so a misbehaving peer
@@ -150,7 +150,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
no-panic criterion (the panic lives in `mod.rs`, the vocabulary
task's file, whose criterion enumerated variants but not panic
arms). Fix: unassigned codes map to `GeneralFailure` (the RFC's
catch-all) — `src/vendor/mod.rs` `from_u8` + a pinning test
catch-all) applied to `src/vendor/mod.rs`'s `from_u8` + a pinning test
(`reply_error_unassigned_codes_map_to_general_failure`); recorded
in `vendor.md`'s adjustment list (same change, check-6 discipline).
Post-fix suite: 38 + 32 tests, clippy/fmt/wasm/doc green.
@@ -163,11 +163,11 @@ Config-Default/`write_all` notes; `last_updated` bumped).
- **F-3 (minor, accepted):** `Cargo.toml`'s `[features]` section
carries inline comments (AGENTS.md convention 1 says no comments in
code; `Cargo.toml` policy comments mirroring a pinned ADR boundary
are the established sibling posture — alktunnels/alktty carry none,
are the established sibling posture: alktunnels/alktty carry none,
but the note is ADR-009's empty-features contract, load-bearing
until the features populate). Accepted; no fix demanded.
- **F-4 (minor, accepted):** `ToSocketAddrs`' error text still names
the cut `resolve_dns()` — verbatim upstream string, documented in
the cut `resolve_dns()`, a verbatim upstream string documented in
the wire-vocabulary task's Notes and the module doc. Fidelity keep.
## References
@@ -186,7 +186,7 @@ Config-Default/`write_all` notes; `last_updated` bumped).
green), and their boundary corrections were honest and documented
(`mod.rs` vocabulary placement; `SocksServerError` early landing).
One inaccuracy found: the wire-vocabulary task's acceptance list
claimed "No unwrap outside tests" scope as file-local — the F-1
claimed "No unwrap outside tests" scope as file-local, and the F-1
panic arm lived in `mod.rs` and was missed by the per-task framing
(each task verified its own files); the review's whole-vendor sweep
is what caught it. Task summaries left as-is (historical record);
+34 -34
View File
@@ -1,6 +1,6 @@
---
id: implementation/vendor-client-session
name: Vendor — the client session (Socks5Stream::use_stream path)
name: "Vendor (the client session, Socks5Stream::use_stream path)"
status: completed
depends_on: [implementation/vendor-wire-vocabulary]
scope: narrow
@@ -11,8 +11,8 @@ level: implementation
## Description
Extract the client session — the consumer half's stream-session base
(ADR-002; POC #1's fit) — into `src/vendor/client.rs`: the
Extract the client session, the consumer half's stream-session base
(ADR-002; POC #1's fit) into `src/vendor/client.rs`: the
`Socks5Stream<S>::use_stream` path plus its `AsyncRead`/`AsyncWrite`
impls, generic over the backing stream. The consumer layer (a later
task group) opens a channels `BiStream` and feeds it here; the
@@ -26,51 +26,51 @@ shape (vendor.md's extraction table).
`which_method_accepted`, `use_password_auth`, `request_header`,
`read_request_reply`, `get_socket`/`get_socket_ref`/`get_socket_mut`.
2. The `AsyncRead`/`AsyncWrite` impls for `Socks5Stream<S>` (upstream
`client.rs:641–681`) verbatim — the consumer's session rides these
`client.rs:641–681`) verbatim; the consumer's session rides these
(a `Socks5Stream` is itself a stream, so `pump_bidi`-shaped
consumers and the front doors can pipe through it).
3. The client `Config` struct — **pruned**: upstream carries
3. The client `Config` struct, **pruned**: upstream carries
`connect_timeout` (only consumed by the cut `connect_raw`) and
`skip_auth` (consumed by `use_stream`). Keep `skip_auth`; drop
`connect_timeout` and its setter (its only consumer is cut). If
dropping it leaves `Config` a single-field struct, keep it anyway
(fidelity: the field is load-bearing in `use_stream`'s handshake
branch) or inline the flag — implementer's choice, but the
branch) or inline the flag, which is the implementer's choice, but the
`use_stream` behavior must stay byte-identical.
**What is NOT extracted** (vendor.md's cut table row 2):
- `Socks5Datagram` (struct + impl — the kernel `UdpSocket` associate
- `Socks5Datagram` (struct + impl, the kernel `UdpSocket` associate
client; the crate's associate session is first-party in
`consumer.rs` per ADR-003's handle swap).
- The `Socks5Stream<TcpStream>` convenience impl
(`connect`/`connect_with_password`/`connect_raw` — the
(`connect`/`connect_with_password`/`connect_raw`, the
`tokio::net::TcpStream` dialers; the consumer's transport is a
channels `BiStream`, not a TCP dial).
**Extraction-time adjustments** (mechanical):
- Drop `anyhow::Context` — upstream wraps nearly every client error in
- Drop `anyhow::Context`: upstream wraps nearly every client error in
`.context(...)`. The error surface is `SocksError` (thiserror):
replace `.context("...")?` with plain `?` where the error variant
already carries the meaning, and with the typed variants
(`SocksError::Io`, `AuthMethodUnacceptable`, ...) where upstream
used `anyhow::Error::msg(...).into()` (upstream `client.rs:285` —
the missing-target-addr case) or `SocksError::Other(anyhow!(...))`
(upstream `client.rs:528` — but that site is inside the cut
used `anyhow::Error::msg(...).into()` (upstream `client.rs:285`, the
missing-target-addr case) or `SocksError::Other(anyhow!(...))`
(upstream `client.rs:528`, but that site is inside the cut
`Socks5Datagram`, so it goes with the cut). No behavior change to
the wire: the same bytes are read/written; only the error typing
changes.
- Drop `log` calls (`debug!` in the extracted region).
- `use_password_auth`'s `unreachable!()` (upstream `client.rs:207` —
- `use_password_auth`'s `unreachable!()` (upstream `client.rs:207`:
`methods.get(1)` being `Some(AuthenticationMethod::None)`):
convention 2 forbids panics in library code. Replace with a typed
error path (`SocksError::AuthenticationRejected(...)` or an
`AuthMethodUnacceptable`) — the state is reachable only by misuse
`AuthMethodUnacceptable`); the state is reachable only by misuse
of the public API, but the faithful translation is an error return,
not a panic.
- `client_src`-style `"[::]:0".parse().unwrap()` sites are all inside
the cut `Socks5Datagram` region — verify none remain.
the cut `Socks5Datagram` region, so verify none remain.
## Acceptance Criteria
@@ -87,9 +87,9 @@ shape (vendor.md's extraction table).
- [x] `Socks5Stream::use_stream` over an in-memory duplex completes:
no-auth handshake, password handshake (both accept and reject
paths), CONNECT request + reply read (success and each mapped
failure code), domain-target and IP-target request encoding —
failure code), domain-target and IP-target request encoding,
against a raw-byte mock server (pinned byte sequences written
directly, not the vendored server typestate — keeping this
directly, not the vendored server typestate; this keeps this
task parallel with the typestate extraction and the client's
wire expectations uncorrelated from the server implementation;
tokio duplex streams, no sockets)
@@ -106,7 +106,7 @@ shape (vendor.md's extraction table).
- docs/architecture/decisions/002-protocol-base.md (the client side:
`Socks5Stream<S>::use_stream`)
- docs/architecture/decisions/003-udp-handle-swap.md (why
`Socks5Datagram` is cut — the associate session is first-party)
`Socks5Datagram` is cut, since the associate session is first-party)
- docs/research/poc-connect-wasm-findings.md §Wire/API notes
(`Socks5Stream::request` target construction; the
`AuthenticationRejected` unit-variant note)
@@ -118,13 +118,13 @@ shape (vendor.md's extraction table).
- **Verbatim-code lint posture**: no module-level clippy allows were
needed for this file (unlike `server.rs`'s
`wrong_self_convention`) — upstream's client region compiles clean
`wrong_self_convention`): upstream's client region compiles clean
under the newer clippy after the mechanical adjustments. The
posture note stays in the module doc for the re-vendor diff.
- **Clippy `unused_io_amount` fix (the one non-verbatim line)**:
upstream's `request_header` writes with `self.socket.write(&packet
[..padding])` (upstream `client.rs:331`); in-tree it is
`write_all` — `deny(clippy::unused_io_amount)` is on by default and
`write_all`; `deny(clippy::unused_io_amount)` is on by default and
a partial write of a SOCKS5 request header would corrupt the
conversation anyway, so `write_all` is the faithful translation
(byte-identical on the wire for a healthy stream; strictly better
@@ -132,25 +132,25 @@ shape (vendor.md's extraction table).
packet is length-limited) is upstream's, preserved.
- **`anyhow::Error::msg("target addr should be present").into()`**
(upstream `client.rs:285`) became the typed
`SocksError::MissingTargetAddr` variant — added to `SocksError` in
`SocksError::MissingTargetAddr` variant, added to `SocksError` in
`mod.rs` (the wire-vocabulary task's error enum, extended by one
variant; the message text is upstream's, kept as the variant's
`#[error]` string). This is the task's "typed variants where
upstream used `anyhow::Error::msg(...).into()`" adjustment; the
variant is wire-inert (error surface only).
- **`use_password_auth`'s `unreachable!()`** (upstream
`client.rs:207`): upstream's match has three arms —
`client.rs:207`): upstream's match has three arms,
`Some(Password{..})` → Ok, `Some(None)` → `unreachable!()`, `None`
→ `AuthenticationRejected("missing user pass")`. In-tree the
`Some(None)` and `None` arms collapse to a single
`AuthenticationRejected("Authentication rejected, missing user
pass")` return — the same message as upstream's `None` arm, an
error return instead of a panic (convention 2). The state is
pass")` return (the same message as upstream's `None` arm, an
error return instead of a panic, per convention 2). The state is
reachable only by misuse (`auth = Some(AuthenticationMethod::None)`
while the server picks password).
- **`Config` pruned to `skip_auth`**: `connect_timeout` + its setter
are cut with `connect_raw` (their only consumer). With one field
left, the struct stayed (fidelity — the field is load-bearing in
left, the struct stayed (fidelity: the field is load-bearing in
`use_stream`'s handshake branch); `Default` was derived instead of
the manual impl (upstream's manual `Default` only existed to spell
the now-cut field). `set_skip_auth` is upstream verbatim.
@@ -163,8 +163,8 @@ shape (vendor.md's extraction table).
auth-sub-negotiation reply and compared only `is_success`; the
destructured `version` fed only a dropped `debug!`. In-tree the
dropped-log rename would have discarded it entirely; instead it is
checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`) —
the same check every other reply read in this file performs; the
checked against `SOCKS5_VERSION` (→ `UnsupportedSocksVersion`), the
same check every other reply read in this file performs, since the
alternative (an `_version` binding) silently accepts a v4 reply on
the sub-negotiation. Byte-level behavior on a compliant server is
unchanged.
@@ -173,8 +173,8 @@ shape (vendor.md's extraction table).
only via formatting; nothing renamed (the field is written on the
previous line, read in `request_header`).
- **Tests are first-party** (upstream's client test module rides the
cut net region — it tests `connect` against real listeners): the
test module here is written fresh against the crate's test layout —
cut net region, testing `connect` against real listeners): the
test module here is written fresh against the crate's test layout:
raw-byte mock servers over tokio duplex streams, pinned byte
sequences written directly (uncorrelated from the vendored server
typestate, per the task). Coverage: no-auth handshake + CONNECT
@@ -187,14 +187,14 @@ shape (vendor.md's extraction table).
`AuthMethodUnacceptable`), version mismatch (both greeting
directions), missing target addr (typed `MissingTargetAddr`),
UDP ASSOCIATE zero fallback (`[5,3,0,1,0,0,0,0,0,0]`),
`skip_auth` (greeting bypassed — request is the first bytes),
`skip_auth` (greeting bypassed, request being the first bytes),
the full mapped failure-code table (0x01–0x08 →
`SocksError::ReplyError` with the code round-tripped), domain
>255 (→ `ExceededMaxDomainLen(256)`), `tokio::io::copy` through
the session (the `AsyncRead`/`AsyncWrite` impls), and the socket
accessors (`get_socket_ref`/`get_socket_mut`/`get_socket`).
- **`request_header` is exercised directly** in two tests (the
missing-target and the zero-fallback one) — it is private; the
missing-target and the zero-fallback one); since it is private, the
tests live in the same module, so this is in-crate visibility, no
surface change.
@@ -209,11 +209,11 @@ and_methods`, `which_method_accepted`, `use_password_auth`,
impls, and the pruned `Config` (`skip_auth` only). The cut regions
(`Socks5Datagram` struct + impl, the `Socks5Stream<TcpStream>`
convenience impl with `connect`/`connect_with_password`/
`connect_raw`, `Config::connect_timeout` + setter) were not copied —
verified by sweep (only doc-comment provenance mentions remain).
`connect_raw`, `Config::connect_timeout` + setter) were not copied
(verified by sweep; only doc-comment provenance mentions remain).
Extraction-time adjustments applied: `anyhow` dropped (the
`client.rs:285` site became the typed `SocksError::MissingTargetAddr`
variant — one variant added to the vocabulary task's `SocksError`;
variant (one variant added to the vocabulary task's `SocksError`;
the `client.rs:528` `Other(anyhow!(...))` site went with the cut
`Socks5Datagram`), `log` calls dropped (unused bindings renamed),
`use_password_auth`'s `unreachable!()` replaced with an error return
@@ -1,6 +1,6 @@
---
id: implementation/vendor-crate-scaffolding
name: Vendor — crate scaffolding (Cargo.toml, lib.rs, vendor module skeleton, license notice)
name: "Vendor (crate scaffolding: Cargo.toml, lib.rs, vendor module skeleton, license notice)"
status: completed
depends_on: []
scope: narrow
@@ -12,7 +12,7 @@ level: implementation
## Description
Create the `alksocks` crate skeleton so the vendor extraction tasks have
their targets. This is the first implementation work in the repo — `src/`
their targets. This is the first implementation work in the repo, as `src/`
does not exist yet. The crate is born wasm-clean: the default build is
protocol-only with zero kernel-net code (ADR-009 §Amendment, ADR-011
§Amendment), so the wasm check must be green from the first commit.
@@ -24,38 +24,38 @@ Deliverables:
- `name = "alksocks"`, `version = "0.1.0"`, `edition = "2021"`,
`rust-version = "1.88"`, `license = "MIT OR Apache-2.0"`,
description/keywords/categories/repository per the siblings.
- `[features]`: `default = []`, `local = []`, `composed = []` —
- `[features]`: `default = []`, `local = []`, `composed = []`,
declared empty now (ADR-009 pins the two-feature layout; the
features are populated by their modules' later tasks; `composed`
will add the alktunnels dep, `local` will add `tokio/net` +
`socket2`).
- `[dependencies]`: `tokio = { version = "1", default-features = false,
features = ["rt", "sync", "io-util", "macros", "time"] }` (the
wasm-clean subset per AGENTS.md convention 4 — **never
wasm-clean subset per AGENTS.md convention 4, so **never
`features = ["full"]`**), `thiserror = "2"`, `tracing = "0.1"`.
**No `alkcall` yet** — the vendor does not need it; the
**No `alkcall` yet**, since the vendor does not need it; the
producer/consumer tasks pin `alkcall = "0.8.0"` when they land.
- `[dev-dependencies]`: `tokio = { version = "1", features = ["full",
"test-util", "macros"] }` (tests are native-only; they are not
part of the wasm-clean surface).
2. **`src/lib.rs`**: `#![forbid(unsafe_code)]`, brief crate docs
(what the crate is, pointing at `docs/architecture/overview.md` —
doc comments are fine and expected), `pub mod vendor;`.
(what the crate is, pointing at `docs/architecture/overview.md`
since doc comments are fine and expected), `pub mod vendor;`.
3. **`src/vendor/` skeleton** preserving upstream file names so per-file
diffs against the reference checkout stay mechanical (the
maintenance posture, vendor.md §Maintenance Posture):
- `src/vendor/mod.rs` — module doc carrying the provenance note:
- `src/vendor/mod.rs`: a module doc carrying the provenance note:
extracted from fast-socks5 **v1.0.0** (MIT, third-party upstream),
per ADR-011; the extraction cut + carried changes are recorded in
`docs/architecture/vendor.md`; submodule declarations for `server`,
`client`, `util` (stub files, filled by the extraction tasks).
- `src/vendor/server.rs`, `src/vendor/client.rs`,
`src/vendor/util/mod.rs`, `src/vendor/util/stream.rs`,
`src/vendor/util/target_addr.rs` — empty stubs.
`src/vendor/util/target_addr.rs`: empty stubs.
4. **Bundled license notice** at `src/vendor/LICENSE`: the upstream MIT
license text plus attribution (fast-socks5 v1.0.0,
github.com/dizda/fast-socks5), referenced from the vendor module doc
(ADR-011's license obligation — upstream has no per-file headers, so
(ADR-011's license obligation; upstream has no per-file headers, so
the bundled notice is the obligation's carrier).
## Acceptance Criteria
@@ -64,7 +64,7 @@ Deliverables:
- [x] `cargo clippy --all-targets -- -D warnings` clean
- [x] `cargo fmt --check` clean
- [x] `cargo check --target wasm32-unknown-unknown` clean (the wasm gate
is green from birth — no `mio`/`socket2`/`tokio::net` in the graph)
is green from birth, with no `mio`/`socket2`/`tokio::net` in the graph)
- [x] `cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean
- [x] `Cargo.toml` has no `alkcall`, no `anyhow`, no `log`, no
`async-trait`, no `tokio-stream`, no `socket2` in `[dependencies]`
@@ -75,7 +75,7 @@ Deliverables:
## References
- docs/architecture/vendor.md (the vendor spec)
- docs/architecture/decisions/011-inline-vendor.md (§Amendment — the
- docs/architecture/decisions/011-inline-vendor.md §Amendment (the
extraction posture, license obligation, wasm gate)
- docs/architecture/decisions/009-feature-gates.md (the feature layout)
- docs/architecture/overview.md §Dependencies (the tokio subset)
@@ -86,39 +86,39 @@ Deliverables:
## Notes
- **Cargo.toml** follows the alktunnels posture (the closer sibling —
same tokio wasm-clean subset incl. `time`, same dev-dep tokio
- **Cargo.toml** follows the alktunnels posture (the closer sibling with
the same tokio wasm-clean subset incl. `time`, same dev-dep tokio
shape): `exclude` mirrors alktunnels (docs/research/ + tasks/ +
sdd_process.md); keywords/categories/description point at the
SOCKS5 story; repository is the git.alk.dev remote. `[features]`
declares `default`/`local`/`composed` empty (ADR-009 §Amendment —
populated by their modules' tasks); `[dependencies]` is exactly the
task's trio (tokio wasm-clean subset, tracing, thiserror — no
alkcall, no net-shaped anything). A `[lib]` section pins the crate
declares `default`/`local`/`composed` empty (ADR-009 §Amendment,
to be populated by their modules' tasks); `[dependencies]` is exactly the
task's trio (tokio wasm-clean subset, tracing, thiserror, i.e. no
alkcall and no net-shaped anything). A `[lib]` section pins the crate
name, matching the siblings.
- **lib.rs** — `#![forbid(unsafe_code)]`, crate docs (what the crate
- **lib.rs**: `#![forbid(unsafe_code)]`, crate docs (what the crate
is, the never-binds posture, the vendor provenance pointer, the
overview.md link), `pub mod vendor;`. Nothing else: later tasks add
their own modules + re-exports.
- **src/vendor/** skeleton preserves upstream v1.0.0 file names for
mechanical per-file diffs: `mod.rs` (provenance module doc +
submodule declarations), `server.rs`, `client.rs`,
`util/{mod,stream,target_addr}.rs` — the five files the extraction
`util/{mod,stream,target_addr}.rs`: the five files the extraction
tasks fill. Stubs carry one-line module docs naming their filling
task and (for the util files) the cut-region note; `util/mod.rs`
pre-declares `stream` + `target_addr` exactly as upstream does.
`socks4/` is deliberately absent (the cut).
- **src/vendor/LICENSE** — the upstream MIT text verbatim (Copyright
- **src/vendor/LICENSE**: the upstream MIT text verbatim (Copyright
(c) 2025 Jonathan Dizdarevic, per `/workspace/fast-socks5/LICENSE`)
under a provenance header (v1.0.0, github.com/dizda/fast-socks5,
ADR-011 pointer, vendor.md pointer); referenced from the `mod.rs`
module doc via an intra-doc link. The upstream license has no
"MIT License" body-text title line — it opens with "MIT License"
+ copyright; the notice keeps that text verbatim after the header.
"MIT License" body-text title line; it opens with "MIT License"
+ copyright, and the notice keeps that text verbatim after the header.
- Verification ran clean: `cargo test` (0 tests, compiles + runs),
clippy `--all-targets -- -D warnings`, `cargo fmt --check`,
`cargo check --target wasm32-unknown-unknown`, and
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` —
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`, so
the wasm gate is green from birth (the dep graph is tokio-wasm-
subset + tracing + thiserror only).
@@ -1,6 +1,6 @@
---
id: implementation/vendor-differential-tests
name: Vendor — the differential RFC edge-case test suite
name: Vendor (the differential RFC edge-case test suite)
status: completed
depends_on: [implementation/vendor-server-typestate, implementation/vendor-client-session]
scope: moderate
@@ -11,18 +11,18 @@ level: implementation
## Description
Build the crate's RFC edge-case test suite for the extracted vendor —
the differential-testing posture vendor.md pins: "the crate's test
Build the crate's RFC edge-case test suite for the extracted vendor,
per the differential-testing posture vendor.md pins: "the crate's test
suite covers the edge cases the extracted code must keep passing; when
in doubt, diff behavior against the reference checkout." The per-task
unit tests (wire vocabulary, typestate, client session) cover the
happy paths; this task covers the RFC edge cases and the
extraction-fidelity checks, using `/workspace/fast-socks5` as the
behavioral reference (read-only — tests never depend on it at build
behavioral reference (read-only, since tests never depend on it at build
time; the reference is consulted while writing the tests, and the
assertions pin the expected bytes).
Test infrastructure: tokio duplex streams (`tokio::io::duplex`) —
Test infrastructure: tokio duplex streams (`tokio::io::duplex`) with
no sockets, no `local` feature, runs in the default wasm-clean crate's
test suite (tests are native-only via dev-deps; the crate itself stays
wasm-clean). A first-party test harness drives the vendored typestate
@@ -39,8 +39,8 @@ the wire form is the point):
`EmptyUsername`, empty password → `EmptyPassword`, reject path →
0x01/0xff reply then error).
2. **Command-read edge cases**: unknown command byte (0x04+ →
`UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0 —
upstream reads it without checking; pin the upstream behavior),
`UnknownCommand` → 0x07 reply), reserved-byte tolerance (RSV ≠ 0;
upstream reads it without checking, so pin that upstream behavior),
address-type edge cases (ATYP 0x02/0x05+ → `IncorrectAddressType`
→ 0x08 reply), domain-length boundary (255-byte domain OK,
256+ rejected at encode time by `to_be_bytes`), truncated address
@@ -54,11 +54,11 @@ the wire form is the point):
the mapped `ReplyError` (the `from_u8` table), domain-target
request encoding (ATYP=3, len-prefixed), the UDP-ASSOCIATE
zero-target fallback (upstream `request_header`'s
`target_addr: None` branch — pin the 10-byte zero form),
`target_addr: None` branch, the 10-byte zero form being pinned),
`skip_auth` behavior (no method negotiation bytes on the wire).
5. **UDP codec edge cases**: `parse_udp_request` with garbage RSV →
`GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag;
the *caller* refuses — pin the parser's contract),
`GarbageInReserved`, FRAG ≠ 0 passthrough (the parser returns frag,
and the *caller* refuses; pin the parser's contract),
`new_udp_header`/`parse_udp_request` round-trip for v4/v6/domain.
6. **`transfer`/`wait_on_tcp` semantics**: `transfer` pumps both
directions to completion over duplexes (including half-close
@@ -71,14 +71,14 @@ the *same* expectation in-tree, noting the upstream source line in the
test's doc comment where the expectation is non-obvious. Where the
extraction deliberately diverges (the pruned error variants, the
published `new_reply`), the test pins the *in-tree* contract and the
divergence is already recorded in vendor.md — no new divergence may be
divergence is already recorded in vendor.md, so no new divergence may be
introduced by this task.
## Acceptance Criteria
- [x] The six coverage groups above each have at least one test;
all pass in `cargo test`
- [x] Tests use only duplex/in-memory streams — no `TcpListener`/
- [x] Tests use only duplex/in-memory streams, with no `TcpListener`/
`UdpSocket`/`tokio::net` in test code (the suite runs in the
default-feature build)
- [x] Every non-obvious expectation carries a doc-comment note naming
@@ -100,7 +100,7 @@ introduced by this task.
testing guards the RFC edge cases)
- docs/architecture/decisions/002-protocol-base.md (faithful
reply-code mapping)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0 — behavior
- Reference checkout: `/workspace/fast-socks5` (v1.0.0, the behavior
source for the pinned expectations; its `src/lib.rs` test module and
`src/server.rs` test module are the upstream coverage to mirror
where extractable)
@@ -109,14 +109,14 @@ introduced by this task.
## Notes
- **Suite location**: `tests/vendor-differential.rs` (an integration
test — dev-deps only, no crate-surface change). 32 tests over the
test, dev-deps only, with no crate-surface change). 32 tests over the
six coverage groups; tokio duplex pairs throughout, no sockets.
- **Differential method followed**: each non-obvious expectation's
doc comment names the upstream file + line it pins (e.g.
`src/server.rs:903-914` for method-order preference,
`src/client.rs:277-285` for the UDP-ASSOCIATE zero fallback,
`src/server.rs:1236-1242` for the parser/caller FRAG split). The
reference checkout was read, not linked — assertions pin bytes.
reference checkout was read, not linked, so assertions pin bytes.
- **Client-session private-API edge cases**: two wire behaviors are
only reachable through private methods (`request_header`'s
`target_addr: None` zero fallback; the `target_addr` field reset
@@ -125,22 +125,22 @@ introduced by this task.
`0.0.0.0:0` via the vendored `read_address`); the code-path forms
of those tests already live in `src/vendor/client.rs`'s in-module
tests (privacy-scoped), written by the client-session task. No new
divergence introduced — the pin notes cross-reference both.
divergence introduced; the pin notes cross-reference both.
- **Duplex half-close shape**: `DuplexStream`'s shutdown closes the
whole pipe's write side, so the `transfer` half-close-propagation
group is driven with drops (full EOF) rather than
`shutdown()`-per-direction; the test's doc comment records why.
- **First-party-only divergence check**: the only in-tree contracts
the tests pin beyond upstream behavior are the two recorded
carried changes/adjustments (`new_reply` published — group 3; the
typed `MissingTargetAddr` replacing upstream's `anyhow::Error` —
group 4 note). No new divergence introduced.
carried changes/adjustments (`new_reply` published, in group 3; the
typed `MissingTargetAddr` replacing upstream's `anyhow::Error` in
group 4's note). No new divergence introduced.
## Summary
Built the vendor's differential RFC edge-case suite per vendor.md
§Maintenance Posture: `tests/vendor-differential.rs`, 32 tests
covering the six groups — negotiation edge cases (empty/zero method
covering the six groups: negotiation edge cases (empty/zero method
lists → 0xff, server-order preference, 0x04 greeting refusal, EOF
mid-handshake typed error, password sub-protocol empty
username/password + the 0x01/0xff-then-error reject path); command
+43 -41
View File
@@ -1,6 +1,6 @@
---
id: implementation/vendor-server-typestate
name: Vendor — the server typestate (Socks5ServerProtocol + carried changes)
name: Vendor (the server typestate, Socks5ServerProtocol + carried changes)
status: completed
depends_on: [implementation/vendor-wire-vocabulary]
scope: narrow
@@ -11,8 +11,8 @@ level: implementation
## Description
Extract the server typestate — fast-socks5's explicit API, the
producer's conversation driver (ADR-002) — into
Extract the server typestate, meaning fast-socks5's explicit API and the
producer's conversation driver (ADR-002), into
`src/vendor/server.rs`, and apply the two carried changes that touch
this file (BIND's reply surface + the `new_reply` publication). This
is the highest-fidelity-sensitive extraction: the typestate is the
@@ -23,8 +23,8 @@ task group) drives it directly over a channels `BiStream`.
1. `SocksServerError` enum + `to_reply_error` verbatim (the
server-side wire error vocabulary; keep `UnexpectedUdpControlGarbage`
— the ASSOCIATE quiet-wait semantics consume it).
2. `ErrorContext<T>` trait + its two impls (`err_when`) verbatim —
since the ASSOCIATE quiet-wait semantics consume it).
2. `ErrorContext<T>` trait + its two impls (`err_when`) verbatim, i.e.
the typestate's internal error-context idiom, used pervasively in
the extracted region.
3. The typestate core, verbatim:
@@ -39,54 +39,56 @@ task group) drives it directly over a channels `BiStream`.
`password_states` module, `PasswordAuthenticationStarted` alias).
- `auth_method_enums!` macro + `StandardAuthentication` (keep the
macro `#[macro_export]`-free per the same adjustment as
`read_exact!` — `pub(crate) use` scoping; upstream exports it at
`read_exact!` (`pub(crate) use` scoping; upstream exports it at
the crate root).
- `negotiate_auth`, `read_command`, `reply_success`, `reply_error`.
- `transfer` + `wait_on_tcp` (the typestate's own two-pump seam —
vendor.md's extraction table; `transfer` is the interception-point
shape, `wait_on_tcp` is the ASSOCIATE control-stream reader).
- `transfer` + `wait_on_tcp` (the typestate's own two-pump seam;
vendor.md's extraction table says `transfer` is the
interception-point shape and `wait_on_tcp` is the ASSOCIATE
control-stream reader).
- `try_notify!` macro (internal to the extracted region; same
scoping adjustment).
4. **Carried change #2 — `new_reply` publication** (vendor.md's
4. **Carried change #2, the `new_reply` publication** (vendor.md's
carried changes; POC #5 ledger ask #3): the reply encoder becomes
public as a `reply(code, addr)`-shaped constructor. Upstream:
`fn new_reply(error: &ReplyError, sock_addr: SocketAddr) -> Vec<u8>`
(private, `server.rs:1380`). In-tree: `pub fn new_reply(...)`
(or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec<u8>`
— either name; pin one and use it consistently) so the BIND
(or `pub fn reply(code: &ReplyError, addr: SocketAddr) -> Vec<u8>`;
either name; pin one and use it consistently) so the BIND
reply#2 writer and the consumer's reply readers don't re-encode
the 10/22-byte reply form. The vendored `reply_success`/`reply_error`
call it; nothing else changes.
**What is NOT extracted** (the cut — POC #4's gate map + the recipe's
**What is NOT extracted** (the cut, being POC #4's gate map plus the recipe's
two additions; vendor.md's cut table row 1):
- `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`,
`udp_bind_random_port`, `handle_udp_*`/`transfer_udp` (all
kernel-net; the producer drives the typestate directly — POC #2's
shape — and the local backends are first-party tokio code, ADR-008).
kernel-net; the producer drives the typestate directly, being POC #2's
shape, and the local backends are first-party tokio code, ADR-008).
- The legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls,
including the `AsyncRead`/`AsyncWrite` impls and the `Unpin` impl —
including the `AsyncRead`/`AsyncWrite` impls and the `Unpin` impl;
they exist only for the legacy API).
- `DnsResolveHelper` trait + impl (net-gated upstream; the wrapper
resolves nothing — domain targets pass through unresolved, ADR-012).
resolves nothing, since domain targets pass through unresolved,
ADR-012).
- The legacy `Config<A>` + `Authentication` trait + impls
(`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`,
`authenticate_callback`) — the recipe's addition: ungated upstream
`authenticate_callback`) which is the recipe's addition: ungated upstream
but consumed only by the cut legacy region. With it go the
`#[allow(deprecated)]` attributes on the cut impls.
- The net-only test module (`test_bind` — binds a real listener).
- The net-only test module (`test_bind`, which binds a real listener).
**Extraction-time adjustments** (mechanical):
- Drop `anyhow::Context` imports and `log` calls (`trace!`/`debug!`/
`error!`/`warn!` in the extracted region — drop or re-point at
`tracing`; the typestate's traces are per-field debug logs, drop
them; keep behavior identical).
`error!`/`warn!` in the extracted region; drop or re-point at
`tracing`. The typestate's traces are per-field debug logs, so drop
them, keeping behavior identical).
- `reply_error`'s `"0.0.0.0:0".parse().unwrap()` (upstream
`server.rs:945`): replace the `unwrap` with a `const` parsed at
compile time or a documented-infallible construction (AGENTS.md
convention 2 — no `unwrap` outside tests; the parse is statically
convention 2: no `unwrap` outside tests, and the parse is statically
infallible, so a `const`/`SocketAddr::new` form is the faithful
translation).
- `Socks5Command::from_u8` returning `Option` + `ok_or` stays (the
@@ -94,11 +96,11 @@ two additions; vendor.md's cut table row 1):
`CommandNotSupported`, RFC-faithful).
- Keep `#[allow(async_fn_in_trait)]` only where upstream had it and
the extracted region still needs it (the `DnsResolveHelper` trait is
cut; check whether any remaining trait needs the attribute —
cut; check whether any remaining trait needs the attribute, though
`AuthMethod` is not async, so likely none).
**The BIND note** (why carried change #1 does not appear here): BIND's
driving is wrapper-side code (POC #5 — "zero upstream ask for BIND
driving is wrapper-side code (POC #5, which found "zero upstream ask for BIND
itself"): the typestate's `read_command()` already returns
`TCPBind`, `reply_success(sock_addr)` is reply#1's carrier, and the
reply#2 writer uses the published `new_reply`. The vendor carries the
@@ -117,18 +119,18 @@ surface BIND needs is present and public.
`Socks5Socket`/`DnsResolveHelper`/`Config`/`Authentication`/
`authenticate_callback` anywhere under `src/vendor/`
(only doc-comment provenance mentions, matching `mod.rs`'s
precedent — no definitions)
precedent, and no definitions)
- [x] No `anyhow`, `log`, `tokio::net`, `socket2`, `tokio-stream`,
`async-trait` anywhere under `src/vendor/`
- [x] `new_reply` (or `reply`) is `pub` and produces byte-identical
output to upstream's private encoder (10 bytes v4 / 22 bytes v6,
VER/REP/RSV/ATYP/addr/port) — unit-tested (kept upstream's
VER/REP/RSV/ATYP/addr/port), unit-tested (kept upstream's
`new_reply` name; the task allowed either)
- [x] `reply_error` contains no `unwrap`/`expect` (convention 2)
- [x] `accept_no_auth` over an in-memory duplex completes a full
negotiation + `read_command` + `reply_success`/`reply_error`
round-trip against a first-party test client (tokio duplex
streams — no sockets), covering: version mismatch refusal,
streams with no sockets), covering: version mismatch refusal,
no acceptable method (0xff reply), unknown command (0x07 reply),
success reply form, error reply form
- [x] `cargo clippy --all-targets -- -D warnings` and
@@ -152,9 +154,9 @@ surface BIND needs is present and public.
## Notes
- **Verbatim-code lint posture**: upstream style trips two
newer-clippy lints in the copied region —
`clippy::wrong_self_convention` (`AuthMethod::new(self, inner)`
— the typestate's method-initializer idiom) and
newer-clippy lints in the copied region:
`clippy::wrong_self_convention` (`AuthMethod::new(self, inner)`,
the typestate's method-initializer idiom) and
`clippy::doc_lazy_continuation` (upstream doc-comment style).
`wrong_self_convention` is allowed at module level with rationale
(churning vendored code would break the mechanical per-file diff
@@ -170,32 +172,32 @@ surface BIND needs is present and public.
- **`try_notify!`'s `error!` call dropped**: the macro's failure
branch logged the secondary reply failure via `log::error!`;
dropped per the adjustments. The branch still awaits
`reply_error` (behavior preserved — the error reply still gets a
`reply_error` (behavior preserved: the error reply still gets a
best-effort attempt; its failure is now silently ignored, which
matches the dropped log's only role).
- **`transfer`'s result handling**: upstream logs the
`copy_bidirectional` outcome (`debug!`/`error!`); in-tree the
result is explicitly discarded (`let _ =`). The function's
contract (run both pumps to completion, absorb the outcome) is
unchanged; the crate's data plane is `pump_bidi` (ADR-050) —
unchanged; the crate's data plane is `pump_bidi` (ADR-050), and
`transfer` rides for fidelity as the interception-point shape.
- **`reply_error`'s unwrap replacement**: upstream parses
`"0.0.0.0:0"` at runtime (infallible but `unwrap`);
in-tree it is a `const V4_UNSPECIFIED_ZERO: SocketAddr` built from
`Ipv4Addr::UNSPECIFIED` — statically infallible, byte-identical
`Ipv4Addr::UNSPECIFIED`, statically infallible and byte-identical
output (the success-reply form test pins the error-reply
BND.ADDR as `0.0.0.0:0`).
- **`auth_method_enums!` scoping**: converted from `#[macro_export]`
to `macro_rules!` + `pub(crate) use` (same adjustment as
`read_exact!`). As with `ready!` in stream.rs, the re-export has no
in-crate consumer yet (the macro's only use is in this file), so
it carries `#[allow(unused_imports)]` with the same rationale —
it carries `#[allow(unused_imports)]` with the same rationale,
kept verbatim so the util/vendor diff stays complete for the
client-session task, which may consume it.
- **Tests are first-party**: upstream's `server.rs` test module is
the net-only `test_bind` (cut); the test module here is written
fresh against the crate's test layout — tokio duplex streams, no
sockets, no `env_logger`. The round-trip suite covers the
fresh against the crate's test layout (tokio duplex streams, no
sockets, no `env_logger`). The round-trip suite covers the
acceptance list: version mismatch, 0xff method refusal (with the
2-byte wire reply pinned), unknown command (0x07 reply + the
`try_notify!` error-reply path), success/error reply forms
@@ -203,7 +205,7 @@ surface BIND needs is present and public.
`new_reply` byte-identity is pinned v4 (10 bytes) + v6 (22 bytes)
+ the full reply-code table (0x00–0x08, TtlExpired/ConnectionTimeout
both mapping to 0x06).
- **`Socks5Command::from_u8` + `ok_or` stays** as upstream — the
- **`Socks5Command::from_u8` + `ok_or` stays** as upstream: the
unknown-command path maps through
`SocksServerError::UnknownCommand` → `CommandNotSupported` (pinned
by the unknown-command test's 0x07 reply byte).
@@ -215,7 +217,7 @@ surface BIND needs is present and public.
directly; the data plane is `pump_bidi`). They ride the extraction
per vendor.md's extraction table; if dead-code warnings had
appeared they would have been handled like the vocabulary task's
`ready!` — none did (pub items in a lib crate are reachable).
`ready!` behavior; none did (pub items in a lib crate are reachable).
## Summary
@@ -233,13 +235,13 @@ alias), the `auth_method_enums!` macro (de-exported to
`negotiate_auth`/`read_command`/`reply_success`/`reply_error`,
`transfer`/`wait_on_tcp`, the `try_notify!` macro, `ErrorContext<T>`
+ impls, and `SocksServerError` (already landed verbatim by the
wire-vocabulary task's early `#[from]` need — now joined by the rest
wire-vocabulary task's early `#[from]` need, now joined by the rest
of the inventory). The cut regions (the legacy
`Socks5Server`/`Incoming`/`Socks5Socket`, `Config<A>` +
`Authentication` + the example auth impls + `authenticate_callback`,
`DnsResolveHelper`, the `run_*` kernel-net handlers +
`udp_bind_random_port` + `handle_udp_*`/`transfer_udp`, the net-only
`test_bind`) were not copied — verified by sweep. Carried change #2
`test_bind`) were not copied, as verified by sweep. Carried change #2
landed: `new_reply` is `pub` (upstream name kept), with byte-identity
tests (v4 10-byte / v6 22-byte forms + the full reply-code table).
Extraction-time adjustments applied: `log` calls dropped (with the
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more