--- id: w2-server-mount name: "Wave 2: SshAdapter::handle mount + russh Config construction" status: completed depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model] scope: moderate risk: medium impact: project level: implementation --- ## Description Land the door frame: the `SshAdapter` type, the ADR-001 mount point, and the russh server `Config` construction from the wave-1 `ServeConfig`. This is the substrate every mapping composes into; the auth bridge and dispatch fill the Handler this task skeletons. - **`SshAdapter`**: holds the `ServeConfig`; construction (`SshAdapter::new`) loads the host keys decrypt-at-construction via the injected `HostKeySource` (ADR-005 decision 6): a locked / absent / decryption-failed / unknown-version load means **no server is constructed**, and the error surfaces as a distinct adapter construction error wrapping the `KeySourceError` variant (never a half-configured adapter, never a keyless server that fails later). - **russh `Config` construction** (ADR-002 surface notes), built from the serve-config: `methods` = `{publickey}` **exactly** (`MethodSet` from `[MethodKind::Publickey]`: honest advertisement, ADR-004 decision 4); `keys` from the loaded host-key set; `window_size` / `maximum_packet_size` / `channel_buffer_size` / `max_auth_attempts` mapped verbatim from `Limits`; `inactivity_timeout` mapped **always-`Some`** (the bounded-resources posture; the crate never constructs a session with no wall clock); russh's rekey `Limits` stay russh-enforced at their RFC-4253 defaults (recorded in the wave-1 config notes; keep that recording). `server_id` stays russh's default posture unless the task finds a family reason otherwise (decide and record). - **The mount**: `SshAdapter::handle(stream)` with a generic stream in, the full russh server stack per stream via `russh::server::run_stream` (edge termination only, with no ALPN code and no relay leg; the `alk/ssh` pin is a ledger entry, ADR-001, and nothing here speaks it). The exact return shape (awaited `RunningSession`, an internal spawn + join handle, …) is decided here against the spawn-and-return posture (ADR-006 decision 4) and the assembly's mounting needs: decide and record; it is semver-visible once consumers exist. - **The Handler skeleton**: the per-connection `russh::server::Handler` impl with the adapter's `type Error` (`From` via thiserror, the wave-2-shaped error surface the wave-1 error-model note reserved); every hook left at its fail-closed russh default (decline/reject). Later wave-2 tasks fill the auth hooks (`w2-auth-bridge`) and the channel/request dispatch (`w2-dispatch-decline-core`). No mapping seams, no feature-gated serving (wave 3); no client dial (wave 4). The feature gates stay untouched, since nothing consumes them yet (the dispatch task is their first `cfg` consumer). ## Acceptance Criteria - [x] `SshAdapter::new(ServeConfig)` loads host keys decrypt-at-construction; locked / absent / decryption-failure / unknown-version → `Err` with the `KeySourceError` variant preserved, no adapter constructed - [x] russh `Config` built from the serve-config: `methods` is exactly `{publickey}`; host keys ride `Config::keys`; the `Limits` knobs map one-for-one with `inactivity_timeout` always `Some` - [x] `SshAdapter::handle` mounts the full russh server stack per generic stream (`run_stream`); no ALPN/relay code exists - [x] The Handler skeleton compiles with an adapter error type (`From`); every hook at its fail-closed default - [x] Tests: honest advertisement (methods exactly `{publickey}`); the knob mapping; construction failure postures (locked vault → no server, absent host keys → no server) - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/decisions/001-alkssh-alpn-pin.md (the mount-point contract: `SshAdapter::handle`, raw vanilla-SSH stream in) - docs/architecture/decisions/002-russh-pin.md (the Config surface notes; honest advertisement) - docs/architecture/decisions/004-auth-dispositions.md (decision 4: `methods = {publickey}`) - docs/architecture/decisions/005-at-rest-key-custody.md (decrypt-at-construction; locked → no server) - docs/architecture/overview.md §Bounded-resources policy - docs/plans/implementation.md §Wave 2 (first bullet) - russh 0.65.0 in-source: `server::Config`, `server::run_stream`, `auth::MethodSet` ## Notes Implementation record (decisions made at this task's discretion, the "decide and record" items): - **The mount's return shape (`handle` runs to completion)** (`Result<(), AdapterError>`): `handle` is an async per-stream call that awaits `run_stream`'s `RunningSession` through the session's end, matching the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler:: handle` is spawned per connection by the assembly's accept loop; alkhttp/src/server/adapter.rs spawns it in tests the same way). ADR-006 decision 4 is satisfied inside russh itself: `run_stream` spawns the session's packet loop on its own task before returning, so the serving path is already spawned-and-returned at the handler-hook level; `handle` does not double-spawn (that would add a task layer and a join-handle surface without adding posture). The alternative candidates (returning `RunningSession`, or an internal spawn + `JoinHandle`) were rejected: the first leaks a generic russh type into the public signature (plus the `Handler` type it parameterizes); the second hides the failure boundary behind a detached task while still not matching the accept-loop shape any better. The chosen shape's signature is `Result<(), AdapterError>` `, the smallest honest surface; it is semver-visible and recorded here. - **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the `Config::default()` posture). No family reason found to change it: the vanilla-ssh wire is vanilla; the version string is interoperability information, not a family advertisement, and POC-3's real-tool evidence rode that posture. Changing it would be policy the crate doesn't own; recorded for any future pin bump. - **The ed25519-only posture holds by construction + the auth bridge, not by a construction check.** The loaded key set is the advertisement; ADR-002 decision 4's "an assembly registers ed25519 fingerprints only" governs what the assembly sources. Flagged for the wave-2 gate: if host-key algorithm filtering ever becomes wire-contract territory (currently it is not, as the key source contract is silent), it would be an ADR amendment, not this task. - **Empty key set = `KeySourceError::Absent` even from a custom source**: `SshAdapter::new` additionally refuses an `Ok(vec![])` load as `Construction(Absent)`, since an empty `Config::keys` would serve no host key honestly, and the `key_source.rs` wave-1 contract already documents the empty set as the absent shape. - **Keepalive stays russh-default on the server direction** (`None` / 3): the `Limits` keepalive knobs are documented client-direction posture (reverse-runner evidence); no server-side keepalive posture exists and none is invented here. - **`SessionHandler` is `pub(crate)`**: the skeleton's type never appears in a public signature (thanks to the run-to-completion return shape), so the public API this task exposes is exactly `SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility when its handler-level tests need it. - Tests ride the wave-1 fixture keys via `include_str!` (the wave-1 src-unit convention) and the real `VaultHostKeySource` through an in-memory vault for the decrypt-at-construction rows; one wire smoke (russh's own client over a tokio duplex through `handle`) proves the full stack mounts and the skeleton declines auth fail-closed with exactly the `{publickey}` advertisement visible on the wire. ## Summary `SshAdapter` is landed as the ADR-001 mount point: `SshAdapter::new` decrypt-loads the host key set at construction (any load failure or empty set → `AdapterError::Construction(KeySourceError variant)`, no adapter) and builds the russh 0.65 server `Config` with honest advertisement (`methods` exactly `{publickey}`), the loaded keys, the `Limits` knobs mapped one-for-one, `inactivity_timeout` always `Some`, and everything else (rekey `Limits`, `auth_rejection_time`, `server_id`, preferred sets) left verbatim at russh's default posture. `SshAdapter::handle` is the edge-termination mount: a generic duplex stream in, `run_stream` per stream, run to completion to `Result<(), AdapterError>` (no ALPN/relay code; the `alk/ssh` pin unspoken). The per-connection `SessionHandler` skeleton (pub(crate)) carries the `Arc` for the auth-bridge/dispatch tasks and stands entirely at russh's fail-closed defaults, with `AdapterError` (`Construction(#[from] KeySourceError)` + `Wire(#[from] russh::Error)`) as its `type Error`. Verification: 12 new tests covering advertisement-exactly-publickey, knob mapping (tuned + documented-default postures), decrypt-at-construction key-set parity, construction failure matrix (locked / absent / empty / tampered / wrong-vault / unknown-version / decode, variant preserved), EOF-on-mount, and a wire smoke (russh client over duplex completing KEX against the fixture host key and observing the fail-closed decline with the exact `{publickey}` method list). `cargo test`, `cargo test --all-features`, `cargo clippy --all-targets -- -D warnings`, `cargo clippy --all-features --all-targets -- -D warnings`, `cargo fmt --check`, and `cargo doc --no-deps` all green.