Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
246 lines
13 KiB
Markdown
246 lines
13 KiB
Markdown
---
|
||
id: w4-dial
|
||
name: "Wave 4: the client dial surface (russh client stack + the connection handle)"
|
||
status: completed
|
||
depends_on: [review-wave-3]
|
||
scope: moderate
|
||
risk: medium
|
||
impact: phase
|
||
level: implementation
|
||
---
|
||
|
||
## Description
|
||
|
||
Land the client direction's substrate, the door facing outward
|
||
(`client.md` §The dial surface): the russh client stack every wave-4
|
||
op row composes into, mirroring `w2-server-mount`'s role for the
|
||
server direction. Everything later in the wave rides the authenticated
|
||
connection handle this task lands.
|
||
|
||
- **The dial surface**: a `connect`-shaped entry taking the wave-1
|
||
[`DialConfig`](the address, the key source, the host-key policy, the
|
||
`Limits`): the russh client `Config` built from the bounded knobs
|
||
(keepalive 15 s / 3 default posture per the reverse-runner evidence
|
||
and `client.md` §Correctness facts; inactivity; window/packet knobs per
|
||
`overview.md`'s table), the dial target as `DialConfig.address`
|
||
carries it. If the dial surface needs a `DialConfig` amendment,
|
||
land + record it here (the `w3-seam-config` course-correction
|
||
pattern), while the wave-1 shape is expected to hold; decide and
|
||
record.
|
||
- **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`]
|
||
load
|
||
happens at dial time; locked / absent / decryption-failure /
|
||
unknown-`key_version` are all **no-dial outcomes** with their
|
||
distinct `KeySourceError` variants surfaced (never env vars, never
|
||
plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07
|
||
already landed in wave 1, consumed here).
|
||
- **Host-key policy at `check_server_key`** (OQ-05), the wave-1
|
||
[`HostKeyPolicy`] seam consumed at russh's client hook (the verdict
|
||
type was pinned against this hook shape in `w1-host-key-policy`):
|
||
the crate default ([`RejectAll`]) connects to nothing; case-1 pins
|
||
hard-decline on mismatch; case-2 assembly policies decide. The
|
||
crate is policy-blind, with no accept-all built-in.
|
||
- **The client Handler skeleton at fail-closed defaults**: the
|
||
russh `client::Handler` impl the dial constructs: server-initiated
|
||
channel opens (`server_channel_open_forwarded_tcpip` /
|
||
`_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined
|
||
cleanly**, never russh's accept-and-drop default (ADR-003 §4, the
|
||
client-side symmetry rule; the acceptor registry that serves them
|
||
is `w4-reverse-listen`'s surface, this task pins the default).
|
||
- **The authenticated connection handle**, the type everything else
|
||
rides: the russh `client::Handle` wrapped with the crate's surface
|
||
(publickey-only authentication from the key source, the family
|
||
contract; no password/keyboard-interactive paths), the policy and
|
||
limits carried, the lifecycle primitives' raw material (full
|
||
cancellation/clean-close shape is `w4-lifecycle`'s).
|
||
- **Test vehicle**, because the client direction dials *vanilla*
|
||
servers, so
|
||
the loopback harness (alkssh server) is not the vehicle: land an
|
||
in-process russh **server double** (a fake `sshd`: a hand-rolled
|
||
`russh::server` handler in `tests/common`) as the contract-test
|
||
vehicle, decide + record its placement and shape. Contract rows:
|
||
dial success (authenticated), each key-source failure posture
|
||
(locked/absent/decryption), policy-decline rows (default-reject,
|
||
pin-mismatch hard decline, case-2 accept), the bounded-config
|
||
wiring observable, server-initiated open declined cleanly.
|
||
- **The smoke posture** decided + recorded (an alkssh-client ↔
|
||
alkssh-server in-process loopback is the cheap real-stack smoke;
|
||
a real-`sshd` dial is wave-5 e2e territory, never silently
|
||
dropped).
|
||
|
||
## Acceptance Criteria
|
||
|
||
- [x] The dial surface lands over `DialConfig` (+ any amendment
|
||
decided and recorded); the russh client `Config` carries the
|
||
bounded knobs (keepalive 15 s / 3 posture, inactivity, window)
|
||
- [x] Key source decrypt-at-dial; every `KeySourceError` failure
|
||
posture is a no-dial outcome with its distinct variant surfaced
|
||
- [x] `check_server_key` calls the injected policy; `RejectAll`
|
||
connects to nothing; pin-mismatch hard-declines; no accept-all
|
||
built-in
|
||
- [x] The client Handler skeleton declines server-initiated opens
|
||
cleanly (fail-closed default pinned by wire rows)
|
||
- [x] The authenticated connection handle lands; authentication is
|
||
publickey-only from the key source
|
||
- [x] The in-process server double lands in `tests/common`; the
|
||
contract rows above green; the smoke posture decided + recorded
|
||
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
|
||
(`-D warnings`), fmt, `cargo doc --no-deps` pass
|
||
|
||
## References
|
||
|
||
- docs/architecture/client.md §The dial surface, §Correctness facts
|
||
(the POC-recorded mechanics), §What the crate does *not* include
|
||
- docs/architecture/decisions/002-russh-pin.md (the client russh
|
||
surface), docs/architecture/decisions/003-feature-graph.md (§4 the
|
||
client-side fail-closed acceptor default this skeleton pins),
|
||
docs/architecture/decisions/005-key-sources.md (decrypt-at-dial)
|
||
- docs/research/russh.md §Client surface (`client::Handler` inventory,
|
||
where `check_server_key` defaults to refuse; the
|
||
`server_channel_open_*` hook set; `client::Config` knobs),
|
||
docs/research/reverse-runner.md (lessons 3/5, with the
|
||
keepalives
|
||
load-bearing, the ephemeral-fleet policy posture)
|
||
- docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing
|
||
facts), docs/research/poc-2-vault-keys.md (the dial lifecycle)
|
||
- src/config.rs (`DialConfig`), src/host_key.rs, src/key_source.rs
|
||
(the wave-1 seams consumed)
|
||
|
||
## Notes
|
||
|
||
### What landed (2026-10-29)
|
||
|
||
- `src/client.rs`: the dial surface, `dial(DialConfig)` over
|
||
[`DialConfig`], the four-step sequence (key load → bounded russh
|
||
`Config` → policy at `check_server_key` → single publickey
|
||
auth), [`SshConnection`] (the connection handle: `limits()`
|
||
/ `address()` / `username()` / `is_closed()` / `close()`, the
|
||
OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s),
|
||
the address parse (`host:port` + the bracket form;
|
||
`DialError::Address` on refusal (dial-time, matching the family's
|
||
no-construction-validation shape), and the doc-hidden
|
||
`russh_client_config` mapping (the bounded-config observable).
|
||
- `src/client_handler.rs`: the client Handler skeleton (the
|
||
mirror of `handler.rs`'s server-direction handler; a separate
|
||
module because `w4-op-rows` / `w4-reverse-listen` grow it), with
|
||
`check_server_key` wired to the injected policy
|
||
(certificates fingerprint their inner key; the verdict passes
|
||
through), every `server_channel_open_*` hook overridden
|
||
explicit-over-inherited with a
|
||
`ChannelOpenFailure::AdministrativelyProhibited` reject (never
|
||
russh's accept-and-drop default), `should_accept_unknown_server_channel`
|
||
the explicit `false`, decline events on the crate vocabulary.
|
||
- `src/error.rs`: [`DialError`], the dial direction's closed
|
||
taxonomy (`KeySource` per-variant preserved / `Address` /
|
||
`HostKeyRejected` / `AuthRejected` / `Wire`), with a manual
|
||
`From<russh::Error>` that upgrades russh's `UnknownKey` (the only
|
||
producer of which is the `check_server_key` decline path) into
|
||
`HostKeyRejected`; hence the `Wire` variant carries no `#[from]`
|
||
(one conversion, one place).
|
||
- `src/config.rs`: the **DialConfig amendment** (below) + doc
|
||
updates; `src/observe.rs`: the client-side decline reasons
|
||
(`no-acceptor`, the skeleton's decline-all default; the registry
|
||
is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`:
|
||
the re-exports (`dial` / `SshConnection` / `DialError` public; the
|
||
handler + config mapping `#[doc(hidden)]` test surface, the
|
||
`serve.rs` pattern).
|
||
- `tests/common/sshd_double.rs`: the fake `sshd` (below);
|
||
`tests/client_dial.rs`: the contract rows incl. the smoke.
|
||
|
||
### Decided + recorded
|
||
|
||
1. **The DialConfig amendment (`username`), the task's sanctioned
|
||
course-correction, the only one.** The wave-1 shape carried
|
||
everything `client.md` §The dial surface names, but the
|
||
USERAUTH_REQUEST structurally requires a user string
|
||
(`authenticate_publickey(user, key)`) and the remote end evaluates
|
||
it, and no dial can exist without one. Decided with the
|
||
`w3-seam-config` pattern: `username: String` is a required
|
||
`DialConfig::new` parameter (no crate default, since a default account
|
||
would be deployment policy smuggled into the crate; the empty
|
||
string is accepted as data and is the remote end's to refuse). It
|
||
is *not identity*, as the identity model stays fingerprint-only
|
||
(ADR-004 decision 5, mirrored client-side), it is never logged
|
||
(the event vocabulary carries no username), and it rides
|
||
[`SshConnection`] as carried dial data. Wave-1 records amended
|
||
(the config module + field docs).
|
||
2. **The server double's placement/shape**
|
||
(`tests/common/sshd_double.rs`): a plain russh server (no alkssh
|
||
in its serving path, hence vanilla-parity) on a **real loopback TCP
|
||
listener** at `127.0.0.1:0` (a dial target is an address the surface
|
||
resolves and connects to; the duplex-pair trick is the
|
||
server-direction harness's, and a dial has no `&mut Handle` slot
|
||
to attach one to). One dial per instance (doubles are cheap); the
|
||
handler resolves fingerprints against an injected accept set (the
|
||
vanilla-`authorized_keys` stand-in) with every other auth hook an
|
||
explicit reject; the `auth_succeeded` hook hands the row the
|
||
per-connection `russh::server::Handle` through a one-shot carrier,
|
||
which is the initiator for the server-initiated-open decline rows (the
|
||
double calls `channel_open_*` at the client and observes each as
|
||
`Err(ChannelOpenFailure(AdministrativelyProhibited))`); a
|
||
per-session outcome channel is the clean-close rows' observable;
|
||
an optional client-session acceptor stub exists for later rows
|
||
(`next_opened_session`).
|
||
3. **The smoke posture**: an alkssh-client ↔ alkssh-server
|
||
in-process loopback over a real TCP socket, landed as the row
|
||
`smoke_alkssh_client_dials_the_alkssh_server_direction`
|
||
(`tests/client_dial.rs`, with both russh stacks, the full auth bridge
|
||
server-side, the full custody + policy chain client-side). A
|
||
real-`sshd` dial is wave-5 e2e territory (`docs/plans/
|
||
implementation.md` §Wave 5); nothing is silently dropped.
|
||
4. **The decline-class mapping**: russh surfaces a policy decline as
|
||
its `UnknownKey` wire error; since the client's `check_server_key`
|
||
hook is the only producer of that class, the manual `From` makes
|
||
it the semantic [`DialError::HostKeyRejected`], while every other russh
|
||
error stays [`DialError::Wire`].
|
||
5. **The skeleton's unknown-type gate** (`should_accept_unknown_
|
||
server_channel` → explicit `false`) is code-pinned, not
|
||
row-pinned: russh's server `Handle` exposes no initiator for an
|
||
unknown channel type, and russh's `ChannelId` is deliberately
|
||
unconstructible outside the library, so no wire row can reach the
|
||
hook. The six reachable hooks (session / direct-tcpip /
|
||
forwarded-tcpip / forwarded-streamlocal / x11 / agent) are
|
||
wire-row-pinned; the `_unknown` open hook is a defensive reject
|
||
behind the gate.
|
||
6. **Auth posture**: exactly one `authenticate_publickey` attempt
|
||
(ed25519-only, ADR-002 decision 4, `None` being the whole
|
||
hash-algorithm posture); a refusal closes the attempt cleanly
|
||
(`Disconnect::ByApplication`) and surfaces
|
||
[`DialError::AuthRejected`]. russh's other auth methods
|
||
(password / kbi / cert / gss / signer-injection, with the signer
|
||
seam dismissed by ADR-005 decision 8) are never reached from
|
||
[`SshConnection`].
|
||
7. **Bounded posture**: the knob mapping keeps the crate's documented
|
||
postures (`keepalive` 15 s / 3, the wall clock always `Some`,
|
||
window/packet/buffer verbatim) and leaves
|
||
russh's own default postures untouched (rekey `Limits`,
|
||
preferred algorithms, `client_id`, `nodelay`. No client-side
|
||
auth-attempt knob exists at this pin and the single-attempt
|
||
contract makes none needed).
|
||
|
||
### Verification
|
||
|
||
`cargo test` (default) and `cargo test --all-features` (and
|
||
per-feature `--features {session,git,tunnel}`) all green, with 14 test
|
||
binaries each, the new `tests/client_dial.rs` carrying 10 rows;
|
||
`cargo clippy --all-features --all-targets` and `cargo clippy
|
||
--all-targets` both `-D warnings` clean; `cargo fmt --check` clean;
|
||
`cargo doc --no-deps` warning-free.
|
||
|
||
## Summary
|
||
|
||
The client direction's substrate landed: `dial(config) ->
|
||
Result<SshConnection, DialError>` runs the fail-closed four-step
|
||
sequence (decrypt-at-dial key load with every no-dial posture
|
||
distinct; the bounded russh client `Config` from `Limits`; the
|
||
injected host-key policy at `check_server_key`, where `RejectAll`
|
||
connects to nothing; a single publickey-only auth attempt) and
|
||
returns the authenticated connection handle everything later in the
|
||
wave rides. One sanctioned `DialConfig` amendment: the required
|
||
`username` field (the USERAUTH_REQUEST's user string; no crate
|
||
default, never identity, never logged). The fail-closed client
|
||
Handler skeleton declines every server-initiated open on the wire
|
||
(the fake-`sshd` double in `tests/common` pins six hook rows +
|
||
decline events), and the alkssh-client ↔ alkssh-server loopback
|
||
smoke is landed as a row. The `w4-op-rows` chain grows this
|
||
skeleton in place. |