feat(server): land the SshAdapter mount + russh Config construction (w2-server-mount)

- SshAdapter:🆕 decrypt-at-construction host-key load (ADR-005
  decision 6) — locked / absent / decryption / unknown-version /
  decode all refuse with AdapterError::Construction(KeySourceError
  preserved), no adapter; an empty key set refuses as Absent even
  from a custom source.
- russh 0.65 server Config built from ServeConfig: methods exactly
  {publickey} (MethodSet from [MethodKind::PublicKey] — honest
  advertisement, ADR-004 decision 4); host keys ride Config::keys;
  Limits knobs (window_size, maximum_packet_size,
  channel_buffer_size, max_auth_attempts) one-for-one;
  inactivity_timeout always Some (bounded-resources posture). All
  other knobs stay russh's default posture: rekey Limits (RFC-4253
  defaults, russh-enforced), auth_rejection_time, server_id
  (russh default — recorded), server keepalive (client-direction
  posture only).
- SshAdapter::handle: ADR-001 mount — generic duplex stream in,
  run_stream per stream, run to completion (Result<(), AdapterError>;
  no ALPN/relay code; the serving loop is spawned inside russh, so
  ADR-006's spawn-and-return holds without double-spawning).
- SessionHandler skeleton (pub(crate)): per-connection
  russh::server::Handler with type Error = AdapterError; every hook
  at the inherited fail-closed default; carries Arc<ServeConfig> for
  w2-auth-bridge / w2-dispatch-decline-core.
- AdapterError (error.rs): Construction + Wire (From<russh::Error>)
  — the wave-1 error-model's reserved wave-2 shape.
- Tests: advertisement-exactly-publickey; knob mapping (tuned +
  default postures); key-set parity; construction failure matrix;
  EOF-on-mount; wire smoke (russh client over duplex: full KEX,
  fail-closed auth decline, exactly the {publickey} method list on
  the wire).

Verified: cargo test (61 across suites), cargo test --all-features,
clippy --all-targets -- -D warnings (default + all-features),
fmt --check, cargo doc --no-deps, taskgraph validate.
This commit is contained in:
glm-5.3-flash committed 2026-10-28 07:51:50 +00:00
1 parent 18ef55c60d
commit b90e89b600
5 files changed
+690 -11

No files matched your search

+476
View File
@@ -0,0 +1,476 @@
//! The server-direction mount point (ADR-001) — [`SshAdapter`], the
//! constructed russh server stack every mapping composes into.
//!
//! Construction ([`SshAdapter::new`], ADR-005 decision 6): the
//! injected [`HostKeySource`] loads **decrypt-at-construction** — a
//! locked / absent / decryption-failed / unknown-version / undecodable
//! load means **no server is constructed**: the error surfaces as
//! [`AdapterError::Construction`] with the original [`KeySourceError`]
//! variant preserved, never a half-configured adapter, never a keyless
//! server that fails later. An empty key set is the same refusal (the
//! host-key source contract's [`KeySourceError::Absent`] shape).
//!
//! The russh server `Config` (ADR-002 surface notes; ADR-004 decision
//! 4): `methods` is `{publickey}` **exactly** (honest advertisement —
//! `MethodSet::from(&[MethodKind::PublicKey][..])`, never russh's
//! `server_supported()`), the loaded host keys ride `Config::keys`,
//! and the [`Limits`] knobs map one-for-one:
//! `window_size` / `maximum_packet_size` / `channel_buffer_size` /
//! `max_auth_attempts` verbatim, `inactivity_timeout` as **always
//! `Some`** — the bounded-resources posture (the crate never
//! constructs a session with no wall clock). Everything else stays at
//! russh 0.65's own default posture (`Config::default()`, left
//! un-touched deliberately): the rekey `Limits` stay russh-enforced at
//! their RFC-4253 §9 defaults, `auth_rejection_time` stays russh's
//! constant-rejection knob, and the preferred-algorithm set stays
//! russh's — the ed25519-only key-algorithm posture holds by the
//! loaded key set itself (ADR-002 decision 4) and by the auth bridge's
//! explicit non-ed25519 decline (`w2-auth-bridge`).
//!
//! The mount ([`SshAdapter::handle`], ADR-001's contract): a generic
//! byte-duplex stream in, the full russh server stack per stream via
//! [`russh::server::run_stream`], returned **run to completion** —
//! the edge-termination shape only, no ALPN code, no relay leg (the
//! `alk/ssh` pin is a ledger entry; nothing here speaks it). The
//! in-flight framing is the alkhttp door precedent: `handle` is an
//! async per-stream call whose completion is the session's end, so the
//! assembly's accept loop mounts per stream via `tokio::spawn` — the
//! serving loop itself stays spawned inside russh (ADR-006 decision 4,
//! which `run_stream` already satisfies: the session's packet loop
//! leaves on its own task), and the mount's return type stays
//! `Result<(), AdapterError>` — no russh type rides the signature.
use std::sync::Arc;
use russh::MethodKind;
use russh::MethodSet;
use russh::keys::PrivateKey;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::config::ServeConfig;
use crate::error::{AdapterError, KeySourceError};
use crate::handler::SessionHandler;
/// The constructed server stack — the substrate every channel-type
/// mapping composes into (ADR-001's `SshAdapter`).
///
/// Built by [`SshAdapter::new`] from the assembly-supplied
/// [`ServeConfig`] (a fail-closed load: see the module docs); mounted
/// per stream by [`SshAdapter::handle`]. Construction is async: the
/// host-key decrypt participates there and nowhere else.
#[derive(Debug)]
pub struct SshAdapter {
serve: Arc<ServeConfig>,
config: Arc<russh::server::Config>,
}
impl SshAdapter {
/// Constructs the adapter — decrypt-at-construction of the host
/// key set (ADR-005 decision 6): a failed or empty load is no
/// server, with the [`KeySourceError`] refused through
/// [`AdapterError::Construction`].
pub async fn new(serve: ServeConfig) -> Result<Self, AdapterError> {
let keys = serve.host_key_source.load().await?;
if keys.is_empty() {
return Err(AdapterError::Construction(KeySourceError::Absent));
}
let config = build_server_config(&serve, keys);
Ok(Self {
serve: Arc::new(serve),
config: Arc::new(config),
})
}
/// The mounted russh server `Config` — in-crate inspection for the
/// contract tests (advertisement and knob mapping); not public
/// surface: the mount's behavior is observable on the wire, never
/// through a russh type leak.
#[cfg(test)]
pub(crate) fn config(&self) -> &russh::server::Config {
&self.config
}
/// The mount (ADR-001): mounts the full russh server stack on one
/// generic duplex byte stream and runs it to completion — an
/// accepted vanilla-SSH transport is authenticated and served on
/// this call's task; the assembly's accept loop mounts per stream
/// with `tokio::spawn(adapter.handle(stream))`.
///
/// The stream is owned edge termination only: no ALPN, no relay
/// leg, no framing interpretation upstream of russh.
pub async fn handle<R>(&self, stream: R) -> Result<(), AdapterError>
where
R: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let handler = SessionHandler::new(Arc::clone(&self.serve));
russh::server::run_stream(Arc::clone(&self.config), stream, handler)
.await?
.await
}
}
/// The adapter's own `Config` construction from the serve-config — the
/// documented subset mapped one-for-one, everything else at russh's
/// default posture (see the module docs; ADR-002 surface notes).
fn build_server_config(serve: &ServeConfig, keys: Vec<PrivateKey>) -> russh::server::Config {
let limits = serve.limits;
russh::server::Config {
methods: MethodSet::from(&[MethodKind::PublicKey][..]),
keys,
window_size: limits.window_size,
maximum_packet_size: limits.maximum_packet_size,
channel_buffer_size: limits.channel_buffer_size,
max_auth_attempts: limits.max_auth_attempts,
inactivity_timeout: Some(limits.inactivity_timeout),
..russh::server::Config::default()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use alkvault::CURRENT_KEY_VERSION;
use russh::client::AuthResult;
use russh::keys::decode_secret_key;
use super::{SshAdapter, build_server_config};
use crate::config::{Limits, ServeConfig};
use crate::error::{AdapterError, KeySourceError};
use crate::key_source::{HostKeySource, VaultHostKeySource};
const NOOP: alkcall::core::auth::NoopIdentityProvider =
alkcall::core::auth::NoopIdentityProvider;
/// The committed test-key fixtures — the wave-1 fixture set, read
/// through `include_str!` per the wave-1 convention (the
/// `tests/common` double module is the tests/-side owner; src-unit
/// tests read the fixture files, never its exports).
const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key");
const BRAVO_PEM: &str = include_str!("../tests/vectors/bravo.key");
const CHARLIE_PEM: &str = include_str!("../tests/vectors/charlie.key");
fn unlocked_vault() -> alkvault::VaultServiceHandle {
let vault = alkvault::VaultServiceHandle::new();
vault.unlock_new(24).expect("in-memory vault unlocks");
vault
}
fn sealed(vault: &alkvault::VaultServiceHandle, pem: &str) -> alkvault::EncryptedData {
vault
.encrypt(pem, CURRENT_KEY_VERSION)
.expect("seals the test PEM")
}
fn serve_config(host_key_source: Arc<dyn HostKeySource>, limits: Limits) -> ServeConfig {
let mut serve = ServeConfig::new(Arc::new(NOOP), host_key_source);
serve.limits = limits;
serve
}
fn static_fixture_host_keys(pems: &[&str]) -> Arc<dyn HostKeySource> {
let keys = pems
.iter()
.map(|pem| decode_secret_key(pem, None).expect("fixture PEM decodes"))
.collect::<Vec<_>>();
struct Static {
keys: Vec<russh::keys::PrivateKey>,
}
#[async_trait::async_trait]
impl HostKeySource for Static {
async fn load(&self) -> Result<Vec<russh::keys::PrivateKey>, KeySourceError> {
Ok(self.keys.clone())
}
}
Arc::new(Static { keys })
}
#[tokio::test]
async fn methods_advertise_exactly_publickey() {
let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default());
let adapter = SshAdapter::new(serve).await.expect("constructs");
let advertised = &adapter.config().methods;
assert_eq!(advertised.len(), 1);
assert_eq!(advertised.first(), Some(&russh::MethodKind::PublicKey));
assert_eq!(
advertised,
&russh::MethodSet::from(&[russh::MethodKind::PublicKey][..])
);
assert_ne!(advertised, &russh::MethodSet::server_supported());
}
#[tokio::test]
async fn limits_knobs_map_one_for_one_with_inactivity_always_set() {
let limits = Limits {
window_size: 1_024,
maximum_packet_size: 4_096,
channel_buffer_size: 7,
max_auth_attempts: 3,
inactivity_timeout: Duration::from_secs(1_337),
..Limits::default()
};
let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), limits);
let adapter = SshAdapter::new(serve).await.expect("constructs");
let config = adapter.config();
assert_eq!(config.window_size, 1_024);
assert_eq!(config.maximum_packet_size, 4_096);
assert_eq!(config.channel_buffer_size, 7);
assert_eq!(config.max_auth_attempts, 3);
assert_eq!(config.inactivity_timeout, Some(Duration::from_secs(1_337)));
}
#[tokio::test]
async fn default_limits_map_to_the_documented_postures() {
let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default());
let adapter = SshAdapter::new(serve).await.expect("constructs");
let config = adapter.config();
assert_eq!(config.window_size, 2_097_152);
assert_eq!(config.maximum_packet_size, 32_768);
assert_eq!(config.channel_buffer_size, 100);
assert_eq!(config.max_auth_attempts, 10);
assert_eq!(
config.inactivity_timeout,
Some(crate::config::DEFAULT_INACTIVITY_TIMEOUT)
);
}
#[tokio::test]
async fn host_keys_load_decrypt_at_construction_into_the_russh_key_set() {
let vault = unlocked_vault();
let blobs = vec![sealed(&vault, BRAVO_PEM), sealed(&vault, CHARLIE_PEM)];
let serve = serve_config(
Arc::new(VaultHostKeySource::new(vault, blobs)),
Limits::default(),
);
let adapter = SshAdapter::new(serve).await.expect("constructs");
let config = adapter.config();
let expected_bravo = decode_secret_key(BRAVO_PEM, None).expect("decodes");
let expected_charlie = decode_secret_key(CHARLIE_PEM, None).expect("decodes");
assert_eq!(config.keys.len(), 2);
assert_eq!(
crate::fingerprint::fingerprint(config.keys[0].public_key()),
crate::fingerprint::fingerprint(expected_bravo.public_key())
);
assert_eq!(
crate::fingerprint::fingerprint(config.keys[1].public_key()),
crate::fingerprint::fingerprint(expected_charlie.public_key())
);
}
#[tokio::test]
async fn locked_vault_at_construction_is_no_adapter() {
let vault = unlocked_vault();
let blobs = vec![sealed(&vault, BRAVO_PEM)];
vault.lock();
let serve = serve_config(
Arc::new(VaultHostKeySource::new(vault, blobs)),
Limits::default(),
);
let err = SshAdapter::new(serve)
.await
.expect_err("locked vault refuses construction");
assert!(
matches!(err, AdapterError::Construction(KeySourceError::Locked)),
"expected Construction(Locked), got {err:?}"
);
}
#[tokio::test]
async fn absent_host_keys_at_construction_is_no_adapter() {
let vault = unlocked_vault();
let serve = serve_config(
Arc::new(VaultHostKeySource::new(vault, Vec::new())),
Limits::default(),
);
let err = SshAdapter::new(serve)
.await
.expect_err("no host keys refuse construction");
assert!(
matches!(err, AdapterError::Construction(KeySourceError::Absent)),
"expected Construction(Absent), got {err:?}"
);
}
#[tokio::test]
async fn empty_set_from_a_custom_source_refuses_construction() {
struct Empty;
#[async_trait::async_trait]
impl HostKeySource for Empty {
async fn load(&self) -> Result<Vec<russh::keys::PrivateKey>, KeySourceError> {
Ok(Vec::new())
}
}
let serve = serve_config(Arc::new(Empty), Limits::default());
let err = SshAdapter::new(serve)
.await
.expect_err("an empty key set serves nothing honestly");
assert!(matches!(
err,
AdapterError::Construction(KeySourceError::Absent)
));
}
#[tokio::test]
async fn every_key_source_variant_surfaces_through_construction() {
let tampered_vault = unlocked_vault();
let tampered = sealed(&tampered_vault, BRAVO_PEM);
let mut tampered = tampered;
tampered.data = "not the ciphertext".into();
let decrypt_failure = Arc::new(VaultHostKeySource::new(tampered_vault, vec![tampered]));
let version_vault = unlocked_vault();
let version_blob = sealed(&version_vault, BRAVO_PEM);
let mut version_blob = version_blob;
version_blob.key_version = 1;
let version_failure = Arc::new(VaultHostKeySource::new(version_vault, vec![version_blob]));
let decode_vault = unlocked_vault();
let decode_failure = Arc::new(VaultHostKeySource::new(
decode_vault.clone(),
vec![sealed(&decode_vault, "this is not a key")],
));
let stranger_vault = unlocked_vault();
let stranger = Arc::new(VaultHostKeySource::new(
unlocked_vault(),
vec![sealed(&stranger_vault, BRAVO_PEM)],
));
for (source, expected) in [
(decrypt_failure, KeySourceError::Decryption(String::new())),
(
version_failure,
KeySourceError::UnknownKeyVersion(String::new()),
),
(decode_failure, KeySourceError::Decode(String::new())),
(stranger, KeySourceError::Decryption(String::new())),
] {
let serve = serve_config(source, Limits::default());
let err = SshAdapter::new(serve).await.expect_err("refuses");
let matched = matches!(
err,
AdapterError::Construction(ref e) if std::mem::discriminant(e)
== std::mem::discriminant(&expected)
);
assert!(matched, "expected {expected:?} class, got {err:?}");
}
}
#[tokio::test]
async fn handle_mounts_the_stack_and_errors_when_the_peer_disconnects() {
let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default());
let adapter = SshAdapter::new(serve).await.expect("constructs");
let (client, server) = tokio::io::duplex(65_536);
drop(client);
let outcome = adapter.handle(server).await;
assert!(
matches!(outcome, Err(AdapterError::Wire(_))),
"expected a wire-class mount failure, got {outcome:?}"
);
}
struct AcceptAllHostKeys;
impl russh::client::Handler for AcceptAllHostKeys {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
#[tokio::test]
async fn wire_smoke_full_stack_mounts_and_skeleton_declines_auth_fail_closed() {
let serve = serve_config(
static_fixture_host_keys(&[BRAVO_PEM]),
Limits {
max_auth_attempts: 3,
..Limits::default()
},
);
let adapter = SshAdapter::new(serve).await.expect("constructs");
let (client_io, server_io) = tokio::io::duplex(65_536);
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
let client_config = Arc::new(russh::client::Config {
inactivity_timeout: Some(Duration::from_secs(30)),
..Default::default()
});
let mut handle = russh::client::connect_stream(client_config, client_io, AcceptAllHostKeys)
.await
.expect("client mounts against the server stack");
let alpha = Arc::new(decode_secret_key(ALPHA_PEM, None).expect("fixture decodes"));
let result = handle
.authenticate_publickey(
"anyone",
russh::keys::PrivateKeyWithHashAlg::new(alpha, None),
)
.await
.expect("auth exchange completes over the wire");
let remaining = match &result {
AuthResult::Failure {
remaining_methods, ..
} => remaining_methods.clone(),
other => {
handle
.disconnect(russh::Disconnect::ByApplication, "test done", "en")
.await
.ok();
panic!("the skeleton's fail-closed posture must not authenticate: {other:?}")
}
};
assert!(
!result.success(),
"the skeleton's every-hook-default posture must not authenticate"
);
assert_eq!(
remaining,
russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]),
"the wire carries exactly the publickey-only advertisement"
);
handle
.disconnect(russh::Disconnect::ByApplication, "test done", "en")
.await
.expect("client disconnects cleanly");
server_task
.await
.expect("server task ends")
.expect("clean session end");
}
#[test]
fn server_config_records_the_russh_default_postures_it_keeps() {
let serve = ServeConfig::new(Arc::new(NOOP), static_fixture_host_keys(&[BRAVO_PEM]));
let config = build_server_config(&serve, Vec::new());
assert_eq!(
config.auth_rejection_time,
std::time::Duration::from_secs(1),
"constant-time rejection stays russh-managed"
);
assert_eq!(
config.keepalive_interval, None,
"the keepalive posture is the client direction's; the server stays russh-default"
);
}
}
+76 -1
View File
@@ -104,9 +104,41 @@ pub enum KeySourceError {
Decode(String),
}
/// The adapter (server-direction) error surface — the two failure
/// classes the mount point can reach, deliberately closed:
///
/// - **Construction** — the [`SshAdapter::new`](crate::SshAdapter::new)
/// host-key load failed: the [`KeySourceError`] variant is preserved
/// verbatim (locked / absent / decryption / unknown-version / decode
/// each stays itself) and **no adapter is constructed** (ADR-005
/// decision 6 — never a half-configured adapter, never a keyless
/// server that fails later).
/// - **Wire** — the russh session stack's own error, mapped
/// transparently (`From<russh::Error>`, the wave-1 error-model's
/// reserved `From` requirement of the per-connection
/// [`russh::server::Handler`]): KEX, transport, and IO failures of
/// a mounted session. Auth declines are *not* this class — they are
/// dispositions (ADR-004), and wire decline reasons are the
/// dispatch's SSH-shaped responses, never this taxonomy.
///
/// There is no catch-all variant: everything the crate's server
/// direction can fail is one of these two classes or a decline.
#[derive(Debug, Error)]
pub enum AdapterError {
/// The host-key load at adapter construction refused — fail-closed
/// (ADR-005 decision 6). The [`KeySourceError`] variant is the
/// original refusal class, preserved un-collapsed.
#[error("ssh server construction refused: {0}")]
Construction(#[from] KeySourceError),
/// A mounted russh session errored (transport / KEX / IO class).
#[error("ssh session error: {0}")]
Wire(#[from] russh::Error),
}
#[cfg(test)]
mod tests {
use super::KeySourceError;
use super::{AdapterError, KeySourceError};
fn all_variants() -> [KeySourceError; 5] {
[
@@ -171,4 +203,47 @@ mod tests {
let _ = v.clone();
}
}
fn all_adapter_variants() -> Vec<AdapterError> {
let mut all: Vec<AdapterError> = all_variants()
.into_iter()
.map(AdapterError::Construction)
.collect();
all.push(AdapterError::Wire(russh::Error::UnknownKey));
all
}
#[test]
fn adapter_error_closed_taxonomy_two_classes() {
let exhaustive: fn(&AdapterError) -> &'static str = |e| match e {
AdapterError::Construction(_) => "construction",
AdapterError::Wire(_) => "wire",
};
for v in all_adapter_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
#[test]
fn construction_preserves_the_key_source_variant() {
for source in all_variants() {
let wrapped = AdapterError::Construction(source.clone());
match wrapped {
AdapterError::Construction(seen) => {
assert_eq!(seen.to_string(), source.to_string())
}
other => panic!("unexpected variant {other:?}"),
}
}
}
#[test]
fn russh_wire_error_is_transparently_mapped() {
let mapped: AdapterError = russh::Error::UnknownKey.into();
assert!(matches!(
mapped,
AdapterError::Wire(russh::Error::UnknownKey)
));
assert_eq!(mapped.to_string(), "ssh session error: Unknown server key");
}
}
+45
View File
@@ -0,0 +1,45 @@
//! The per-connection server handler — the wave-2 skeleton the later
//! wave-2 tasks fill (`w2-auth-bridge` the auth hooks,
//! `w2-dispatch-decline-core` the channel/request dispatch, this file
//! the substrate only).
//!
//! Skeleton posture (AGENTS.md convention + ADR-004): every
//! [`russh::server::Handler`] hook currently stands at its inherited
//! **fail-closed russh default** — auth hooks reject, channel hooks
//! accept nothing, request hooks reply nothing. A client can mount a
//! connection and never cross auth. The skeleton's substance is the
//! error type ([`AdapterError`], `From<russh::Error>` as russh's
//! `Handler` contract requires) and the carried state the later tasks
//! consume: the whole [`ServeConfig`] handle.
//!
//! Fail-closed on the wire, honestly: this is exactly the posture the
//! decline-matrix tasks gate from — the skeleton declines everything
//! by construction, never advertises a capability it does not serve.
use std::sync::Arc;
use crate::config::ServeConfig;
use crate::error::AdapterError;
/// The per-connection handler (one per mounted stream): russh constructs
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted
/// stream and drives it through [`russh::server::run_stream`].
///
/// Carries the serve-config the auth bridge and dispatch consume; at
/// the wave-2 skeleton stage everything it does is inherited — and the
/// inherited surface is fail-closed (auth rejects, channels decline),
/// so a mounted session through this skeleton serves nothing.
pub(crate) struct SessionHandler {
#[allow(dead_code)]
serve: Arc<ServeConfig>,
}
impl SessionHandler {
pub(crate) fn new(serve: Arc<ServeConfig>) -> Self {
Self { serve }
}
}
impl russh::server::Handler for SessionHandler {
type Error = AdapterError;
}
+4 -1
View File
@@ -24,14 +24,17 @@
//! validation are later implementation waves — wave 2 decomposes after
//! this gate). See also `docs/research/` and family ADR-003.
mod adapter;
mod config;
mod error;
mod fingerprint;
mod handler;
mod host_key;
mod key_source;
pub use adapter::SshAdapter;
pub use config::{DialConfig, Limits, ServeConfig};
pub use error::KeySourceError;
pub use error::{AdapterError, KeySourceError};
pub use fingerprint::fingerprint;
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
+89 -9
View File
@@ -1,7 +1,7 @@
---
id: w2-server-mount
name: Wave 2 — SshAdapter::handle mount + russh Config construction
status: pending
status: completed
depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model]
scope: moderate
risk: medium
@@ -57,21 +57,21 @@ yet (the dispatch task is their first `cfg` consumer).
## Acceptance Criteria
- [ ] `SshAdapter::new(ServeConfig)` loads host keys
- [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
- [ ] russh `Config` built from the serve-config: `methods` is exactly
- [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`
- [ ] `SshAdapter::handle` mounts the full russh server stack per
- [x] `SshAdapter::handle` mounts the full russh server stack per
generic stream (`run_stream`); no ALPN/relay code exists
- [ ] The Handler skeleton compiles with an adapter error type
- [x] The Handler skeleton compiles with an adapter error type
(`From<russh::Error>`); every hook at its fail-closed default
- [ ] Tests: honest advertisement (methods exactly `{publickey}`); the
- [x] Tests: honest advertisement (methods exactly `{publickey}`); the
knob mapping; construction failure postures (locked vault → no
server, absent host keys → no server)
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` pass
## References
@@ -91,8 +91,88 @@ yet (the dispatch task is their first `cfg` consumer).
## Notes
> To be filled by implementation agent
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 — 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 — 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)` — `Config::keys` empty 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
> To be filled on completion
`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<ServeConfig>` 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 — 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.