Compare commits
9
Commits
v0.2.0
...
126ae9ccf6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126ae9ccf6 | ||
|
|
398e9340e3 | ||
|
|
e196ed43e3 | ||
|
|
9ca8c52601 | ||
|
|
fc6571425a | ||
|
|
fcacedb21e | ||
|
|
db051dd068 | ||
|
|
b548a47449 | ||
|
|
f56b44589c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,54 +154,54 @@ 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.9.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.9.0"` and bump
|
||||
rather than working around them. Pin `alkcall = "0.10.1"` and bump
|
||||
deliberately. The establishment surface (alkcall ADR-049 +
|
||||
amendments) is load-bearing for this crate: SOCKS5 session opens
|
||||
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
|
||||
|
||||
@@ -279,55 +297,65 @@ cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||||
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
|
||||
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).
|
||||
5. Confirm with `cargo search` / `cargo info`.
|
||||
|
||||
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` /
|
||||
@@ -336,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
|
||||
@@ -388,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.
|
||||
+138
-30
@@ -6,6 +6,111 @@ 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 →
|
||||
0.10.1 (the adoption-window ordering fix) and the alktunnels sibling
|
||||
bumped 0.4.0 → 0.4.1 (its dependency-patch ride). No alksocks API
|
||||
change, no wire change; the fix surface is private upstream, so there
|
||||
is no change on this crate's paths. Patch rather than minor: both
|
||||
dependency bumps are semver-compatible (0.10.x → 0.10.1, 0.4.x →
|
||||
0.4.1), matching the alktunnels 0.4.0 → 0.4.1 ride.
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.10.0 → 0.10.1** (crates.io). The adoption-window
|
||||
ordering fix (review 013, routed from alkgit's wave-2 gate): a
|
||||
chunk routed by the channels demux inside the `adopt_channel`
|
||||
install→drain window was delivered ahead of earlier-parked early
|
||||
arrivals, inverting the channel's byte-stream order (observed
|
||||
downstream as pkt-line desync in alkgit under multi-process load),
|
||||
and a related loss variant (a chunk parked after the drain had
|
||||
removed the queue, parked forever) is closed. The install and its
|
||||
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:
|
||||
the fix surface is private (`ChannelState`), the behavior only
|
||||
strengthens. No API, wire-format, or documented-cap behavior change
|
||||
upstream.
|
||||
- **alktunnels 0.4.0 → 0.4.1** (the dependency-patch ride on alkcall
|
||||
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
|
||||
its own `alkcall` pin (it constructs `ChannelOpenError` values for
|
||||
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).
|
||||
|
||||
## [0.3.0] - 2026-10-19
|
||||
|
||||
The dependency-major release: the alkcall substrate bumped 0.9.0 →
|
||||
0.10.0 (the gateway ownership-seam release) and the alktunnels sibling
|
||||
bumped 0.3.0 → 0.4.0 (its 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 (compile + suite green unchanged), which is
|
||||
why this is a minor bump rather than a patch: the semver-incompatible
|
||||
dependency bumps plus this crate's public re-exports of alkcall types
|
||||
(`SocksOpenError::Open`/`open_ref` carry `ChannelOpenError`) make it
|
||||
semver-visible to downstream consumers (matching the alktunnels
|
||||
0.3.0 → 0.4.0 ride).
|
||||
|
||||
### Changed
|
||||
|
||||
- **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
|
||||
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;
|
||||
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
|
||||
contact points (`ChannelClient::open_channel*`,
|
||||
`ChannelOpenError`, `Establishment::default()`,
|
||||
`register_openable*`, `pump_bidi`, the `core` types) are unchanged
|
||||
between 0.9.0 and 0.10.0. 145 default / 171 all-features tests pass;
|
||||
wasm-clean verified.
|
||||
- **alktunnels 0.3.0 → 0.4.0** (the dependency-major ride on alkcall
|
||||
0.10.0). No alktunnels API/wire change; the composed providers see
|
||||
none of it.
|
||||
- **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
|
||||
produces a duplicate-type compile failure, not silent drift).
|
||||
|
||||
## [0.2.0] - 2026-10-14
|
||||
|
||||
The dependency-major release: the alkcall substrate bumped 0.8.1 →
|
||||
@@ -30,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
|
||||
@@ -40,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
|
||||
@@ -60,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
|
||||
@@ -142,6 +247,9 @@ 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.2.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
|
||||
[0.1.0]: https://git.alk.dev/alkdev/alksocks/releases/tag/v0.1.0
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alksocks"
|
||||
version = "0.2.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -28,15 +28,15 @@ composed = ["dep:alktunnels"]
|
||||
|
||||
[dependencies]
|
||||
# The substrate (convention 11): pinned, bump deliberately, never
|
||||
# transitively. 0.9.0: the stub-sweep release (the marked-pub open
|
||||
# contract, channel/control, channel/resources/subscribe, op/register
|
||||
# forward-through — none on this crate's paths; zero code contact with
|
||||
# the breaking surface, this crate's only Establishment use is
|
||||
# `Establishment::default()` in the session gate).
|
||||
alkcall = "0.9.0"
|
||||
# transitively. 0.10.1: the adoption-window ordering fix (review 013,
|
||||
# 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.3.0: the dependency-major (the alkcall 0.9.0 bump), bug-fix ride,
|
||||
# no alktunnels API/wire change.
|
||||
alktunnels = { version = "0.3.0", 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.3.0"
|
||||
alktunnels = "0.5.0"
|
||||
@@ -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,49 +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
|
||||
because the semver-incompatible dependency bumps + this crate's
|
||||
alkcall type re-exports make it semver-visible downstream.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
@@ -247,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
├── alkcall 0.9.0 (core types: Connection, ProtocolHandler, BiStream,
|
||||
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.3.0 (the direct op egress; the bind-first
|
||||
listen accept; ADR-008's composed providers) — its default
|
||||
└── adds: alktunnels 0.4.1 (the direct op egress; the bind-first
|
||||
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.3.0: 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)
|
||||
|
||||
|
||||
@@ -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, ®istry, auth, witness) — the establisher rides
|
||||
// register(&core, ®istry, auth, witness): the establisher rides
|
||||
// the registration (the optional witness is the CF-006 test probe).
|
||||
producer.register(&core, ®istry, auth, None)?;
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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/)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alksocks = { path = "../.." }
|
||||
alkcall = "0.9.0"
|
||||
alkcall = "0.10.1"
|
||||
serde_json = "1"
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["rt", "io-util", "macros"], default-features = false }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user