Files
alkssh/tasks/implementation/w4-dial.md
T
glm-5.3-flash 0d3fd48f5d feat(client): land the dial surface — the authenticated connection handle + the fail-closed client handler skeleton (w4-dial)
The client direction's substrate (client.md §The dial surface), the
mirror of w2-server-mount: dial(DialConfig) runs the fail-closed
four-step sequence — decrypt-at-dial key load (every KeySource
posture a distinct no-dial outcome), the bounded russh client Config
from Limits (keepalive 15 s / 3, the wall clock always Some, window/
packet/buffer verbatim), the injected host-key policy at
check_server_key (RejectAll connects to nothing; UnknownKey maps to
DialError::HostKeyRejected — its only producer), and exactly one
publickey-only auth attempt — and returns the authenticated
SshConnection handle everything else in the wave rides.

- DialConfig amendment (the sanctioned course-correction, recorded):
  the required `username` field — the USERAUTH_REQUEST's user string;
  no crate default, never identity, never logged (ADR-004 decision 5).
- The client Handler skeleton (src/client_handler.rs): every
  server_channel_open_* hook overridden explicit-over-inherited with
  a clean AdministrativelyProhibited reject (never russh's
  accept-and-drop default, ADR-003 §4); should_accept_unknown_
  server_channel the explicit false; decline events on the crate
  vocabulary (no-acceptor / never-feature reasons).
- The in-process fake sshd (tests/common/sshd_double.rs): the
  client-direction contract vehicle — a plain russh server on a real
  loopback TCP listener, accept-set identity resolution, the auth_
  succeeded handle carrier for the server-initiated-open rows, the
  per-session outcome channel for the clean-close rows.
- tests/client_dial.rs: 10 contract rows — dial success, the five
  key-source no-dial postures, RejectAll / pin-mismatch / case-2
  policy rows, refused auth, the malformed-address dial-time refusal,
  the bounded-config observable, the six-hook decline wire rows with
  decline events, and the smoke: alkssh-client ↔ alkssh-server over a
  real TCP loopback (real-sshd dial stays wave-5 e2e).

Verification: cargo test + per-feature + --all-features (14 binaries,
all green), cargo clippy ×2 (-D warnings), cargo fmt --check,
cargo doc --no-deps — all clean. taskgraph validate: 35 tasks valid.
2026-10-29 14:07:15 +00:00

13 KiB
Raw Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w4-dial Wave 4 — the client dial surface (russh client stack + the connection handle) completed
review-wave-3
moderate medium phase implementation

Description

Land the client direction's substrate — the door 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 — 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 — 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) — the wave-1 shape is expected to hold; decide and record.
  • Key source decrypt-at-dial (ADR-005) — 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 — 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 — 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 — 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

  • 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)
  • Key source decrypt-at-dial; every KeySourceError failure posture is a no-dial outcome with its distinct variant surfaced
  • check_server_key calls the injected policy; RejectAll connects to nothing; pin-mismatch hard-declines; no accept-all built-in
  • The client Handler skeleton declines server-initiated opens cleanly (fail-closed default pinned by wire rows)
  • The authenticated connection handle lands; authentication is publickey-only from the key source
  • The in-process server double lands in tests/common; the contract rows above green; the smoke posture decided + recorded
  • 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 — check_server_key defaults to refuse; the server_channel_open_* hook set; client::Config knobs), docs/research/reverse-runner.md (lessons 3/5 — 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, 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) — 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 — no dial can exist without one. Decided with the w3-seam-config pattern: username: String is a required DialConfig::new parameter (no crate default — 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 — 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 — vanilla-parity) on a real loopback TCP listener (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 — 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 — 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] — 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 is 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 — the signer seam is 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 — 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 — 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.