Files
alkssh/src/adapter.rs
T
glm-5.3-flash 3d0253d981 feat(tunnel): land the -R listen flow + the forwarded-tcpip data leg (w3-tunnel-listen)
The tunnel branch's other half is served for real against published
alktunnels 0.4.1 / alkcall 0.10.1, with POC-3 §C.4 as the template:

- tcpip_forward/streamlocal_forward (+ both cancel forms) map the
  vanilla listen request family-ward through TunnelListener::open on
  the held ChannelClient (bounded inline dial — the global request's
  reply + the &mut u32 bound-port out-param are the hook's); the
  wire bind endpoint rides as the resource string verbatim (OpenSSH
  client-side normalization parity). A reply without the minted
  listen token/bound report declines and reaps its fabric listener.
- the door serves the fabric's inbound tunnel/forwarded opens: the
  serve config gains TunnelListenSeam (the seam-config second
  course-correction — the shared ServingConfig registry the assembly
  wired at from_connection_with_serving); alkssh registers the
  forwarded openable (ChannelCore over a clone of the held client's
  manager) at SshAdapter::new — a failed registration is a failed
  construction (AdapterError::ListenRegistration).
- the forwarded-tcpip data leg: token correlation through the shared
  ListenRouter → the in-crate per-session channel cap at the accept
  surface (the acceptor vocabulary) → Handle::
  channel_open_forwarded_tcpip streaming the listen request's own
  args + the actually-bound port (sshd parity) with
  TunnelForwardedParams.peer as the originator → pump_bidi(ssh,
  fabric). Correlation-miss → teardown-at-birth, never a half-open
  SSH channel. Unix arrivals ride forwarded-streamlocal@openssh.com.
- bounded bookkeeping: the per-session listener map packet-loop-only
  with a dedicated max_concurrent_channels-ceiling counter; the
  route map bounded by construction (mints at accepted listens,
  removed at cancels + session teardown — the handler's Drop).
- the harness grew the client-side accept hook
  (server_channel_open_forwarded_tcpip/_streamlocal,
  acceptor-driven) + a hand-rolled tracing subscriber; 18 contract
  rows + both real-tool smokes (ssh -L, ssh -R + curl) green in
  tests/tunnel_mapping.rs; the wave-2 stub listen rows migrated per
  build (unfeatured / seam-unconfigured).

Verified: cargo test across default/git/session/sftp/tunnel and all
combos + --all-features; clippy -D warnings (default, all-features,
combos); cargo fmt --check; cargo doc --no-deps clean;
taskgraph validate (26 tasks).
2026-10-29 04:24:50 +00:00

494 lines
19 KiB
Rust

//! 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;
#[cfg(feature = "tunnel")]
use crate::tunnel_map;
/// 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 serve = {
#[cfg(not(feature = "tunnel"))]
{
serve
}
#[cfg(feature = "tunnel")]
{
let mut serve = serve;
if serve.channel_client.is_some() && serve.tunnel_listen_seam.is_some() {
tunnel_map::install_forwarded_surface(&mut serve)
.map_err(AdapterError::ListenRegistration)?;
}
serve
}
};
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"
);
}
}