Files
alkssh/src/client_handler.rs
T
glm-5.3-flash 1e583ab55b docs(comments): sweep em dashes from source comments, test prose, and manifest
Same treatment for the prose the compiler carries: doc and line comments
across src/ and tests/, test assertion and error-message strings (prose
surfaces: the tunnel-map registration/establish errors, the key-source
decode payload, assertion messages), scripts/matrix.sh comment lines,
the fuzz sidecar's comments and README (corpus seeds untouched), and the
Cargo.toml comment headers plus the crates.io description. The AGENTS.md
no-em-dash rule (convention 14) now holds for the whole tracked tree.

Verification: repo-wide git grep is clean (single exempt hit: the
verification command's own search pattern), cargo test (18 suites, zero
failures, default and per-feature builds via scripts/matrix.sh), clippy
--all-targets -D warnings and clippy all-features, fmt --check, doc
--no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
2026-11-04 20:28:05 +00:00

475 lines
18 KiB
Rust

//! The per-connection **client** handler, the dial surface's russh
//! `client::Handler` impl (`w4-dial`), the mirror of
//! [`crate::handler`]'s server-direction handler.
//!
//! Two responsibilities, both fail-closed:
//!
//! 1. **The host-key policy at `check_server_key`** (OQ-05): the hook
//! receives the server's presented key, computes its fingerprint on
//! the crate's single fingerprint path ([`crate::fingerprint`]),
//! labels it with the dial target (host + port, parsed from the
//! dial address), and hands the whole [`PresentedHostKey`] to the
//! injected [`HostKeyPolicy`], and the verdict rides straight
//! through (`Ok(policy.decide(..).await)`). The crate is policy-blind; the
//! built-in [`RejectAll`](crate::RejectAll) verdict makes every dial
//! refuse; a `false` verdict is the decline disposition russh
//! surfaces as its `UnknownKey` wire class
//! ([`DialError::HostKeyRejected`](crate::DialError)),
//! never an accept, never a retry).
//! 2. **Server-initiated channel opens declined cleanly** (ADR-003 §4,
//! the client-side symmetry rule): russh's default posture for every
//! `server_channel_open_*` hook is accept-and-drop, a
//! half-capability the family forbids. Every hook is overridden
//! explicit-over-inherited. The `forwarded-tcpip` /
//! `forwarded-streamlocal` hooks (the `-R` data leg's types) route
//! through the **acceptor registry** ([`crate::client_accept`],
//! the `w4-reverse-listen` surface): served only under a registered
//! acceptor for that type and under the `tunnel` feature (the
//! [`with_tunnel_surface`]'s tunnel surface
//! wiring; a build without `tunnel` has no registry, so every hook
//! declines); unregistered/unfeatured opens answer
//! [`ChannelOpenFailure::AdministrativelyProhibited`], a clean
//! wire-visible refusal the peer observes as an
//! unconfirmed open, never an accepted-then-dropped channel. The
//! accepted open's handoff spawns (the spawn-and-return rule's
//! client mirror): the hook accepts and returns fast, the duplex +
//! the open's fields ride the payload to the assembly's acceptor
//! call on its own task. Every other type has no registry slot:
//! the unknown-type gate
//! ([`Handler::should_accept_unknown_server_channel`]) answers
//! `false` (the hook's reject-before-open form; its `_unknown`
//! sibling stays a defensive reject, wire-reachable only if that
//! gate ever changed, also fail-closed), and the never-feature rows
//! (x11, agent) decline at any configuration.
//!
//! The accepted-open handoff carries a **bounded bookkeeping**
//! counter (`AGENTS.md` convention 9, the cap relation recorded in
//! [`crate::client_accept`]): a dedicated per-connection counter
//! (sharing the dial's `Limits::max_concurrent_channels` ceiling the
//! server direction's per-session cap uses), admitted in the hook
//! before the accept, released when the handoff task ends (the
//! acceptor's call completing *or the task unwinding*;
//! [`crate::client_accept`]'s `HandoffCapGuard` panic-safety); a full
//! counter declines the arrival cleanly
//! (`cap-exceeded`).
#[cfg(feature = "tunnel")]
use std::sync::Arc;
use russh::ChannelOpenFailure;
use russh::client::ChannelOpenHandle;
use russh::client::Msg;
use russh::keys::PublicKeyOrCertificate;
use russh::{Channel, ChannelId};
#[cfg(feature = "tunnel")]
use crate::channel_cap::ChannelCap;
#[cfg(feature = "tunnel")]
use crate::client_accept::{
AcceptorRegistry, handoff_forwarded_streamlocal, handoff_forwarded_tcpip,
};
#[cfg(feature = "tunnel")]
use crate::config::Limits;
use crate::host_key::{HostKeyPolicy, PresentedHostKey};
use crate::observe;
use crate::{DialError, fingerprint};
/// The `-R` leg's per-connection client-handler state (the
/// `w4-reverse-listen` surface, under `tunnel`): the acceptor registry
/// the two forwarded hooks route through, plus the accepted-open
/// handoff counter and its ceiling (the dial's
/// `Limits::max_concurrent_channels`; released when the handoff task
/// ends, completion or unwind, `HandoffCapGuard`'s panic-safety).
#[cfg(feature = "tunnel")]
pub(crate) struct TunnelSurface {
acceptors: Arc<AcceptorRegistry>,
limits: Limits,
open_cap: std::sync::Arc<ChannelCap>,
}
/// The per-connection client handler the dial surface constructs;
/// see the module docs. Test surface
/// (`#[doc(hidden)]` re-export from the crate root): the contract
/// tests drive the bare hooks, exactly the
/// `SessionHandler` §Test-surface pattern's client mirror.
pub struct ClientHandler {
policy: std::sync::Arc<dyn HostKeyPolicy>,
host: String,
port: u16,
/// The `-R` surface under `tunnel` (`Some`, the dial wires it;
/// the bare-hook tests construct without it to pin the decline-all
/// default posture in the featured builds too).
#[cfg(feature = "tunnel")]
tunnel: Option<TunnelSurface>,
}
impl ClientHandler {
/// Builds the handler over the injected policy and the dial
/// target's endpoint labels (host + port; the presented key's
/// decision context, see `check_server_key`).
pub fn new(
policy: std::sync::Arc<dyn HostKeyPolicy>,
host: impl Into<String>,
port: u16,
) -> Self {
Self {
policy,
host: host.into(),
port,
#[cfg(feature = "tunnel")]
tunnel: None,
}
}
/// Wires the `-R` surface (the dial's step under `tunnel`): the
/// acceptor registry (constructed empty at the dial; the assembly
/// installs through the connection's accessor) + the
/// accepted-open counter against the limits' channel ceiling.
#[cfg(feature = "tunnel")]
pub fn with_tunnel_surface(self, acceptors: Arc<AcceptorRegistry>, limits: Limits) -> Self {
Self {
tunnel: Some(TunnelSurface {
acceptors,
limits,
open_cap: Arc::new(ChannelCap::default()),
}),
..self
}
}
}
impl russh::client::Handler for ClientHandler {
type Error = DialError;
/// The host-key policy's hook (OQ-05): the presented key or
/// certificate's fingerprint is computed on the crate's single
/// fingerprint path (certificates fingerprint their inner key;
/// the trust decision is the policy's either way) and the whole
/// [`PresentedHostKey`] rides the policy; the verdict is passed
/// through untouched. `Ok(false)`, russh's decline, is the
/// disposition, never an error path; the policy's own record
/// carries why.
async fn check_server_key(
&mut self,
server_public_key: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let presented = PresentedHostKey {
host: self.host.clone(),
port: self.port,
fingerprint: fingerprint(&server_public_key.public_key()),
};
Ok(self.policy.decide(&presented).await)
}
/// Unknown channel types never come through this way: the
/// explicit `false` (the fail-closed default, written down to
/// survive a pin bump).
async fn should_accept_unknown_server_channel(
&mut self,
_id: ChannelId,
_channel_type: &str,
) -> bool {
false
}
async fn server_channel_open_session(
&mut self,
channel: Channel<Msg>,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("session", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
/// The `forwarded-tcpip` hook (the `-R` data leg's gate): the
/// registry's slot read at arrival time (registered + room →
/// accept and hand the assembly the open on a spawned task; no
/// acceptor / no room → the clean decline, never accept-and-drop.
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: Channel<Msg>,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] connected_address: &str,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] connected_port: u32,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] originator_address: &str,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
#[cfg(not(feature = "tunnel"))]
{
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
#[cfg(feature = "tunnel")]
{
let Some(surface) = self.tunnel.as_ref() else {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
let Some(acceptor) = surface.acceptors.tcpip_acceptor() else {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
if !surface
.open_cap
.admit(surface.limits.max_concurrent_channels)
{
observe::decline_open("forwarded-tcpip", "cap-exceeded", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
tokio::spawn(handoff_forwarded_tcpip(
acceptor,
Arc::clone(&surface.open_cap),
channel,
reply,
connected_address.to_owned(),
connected_port,
originator_address.to_owned(),
originator_port,
));
Ok(())
}
}
/// The `forwarded-streamlocal` hook, the unix-slot's mirror of
/// the tcpip gate (the ingestion is platform-neutral: no runtime
/// gate here; the listen rows carry the OQ-11 gate on the bind,
/// the recorded asymmetry (see [`crate::client_accept`]).
async fn server_channel_open_forwarded_streamlocal(
&mut self,
channel: Channel<Msg>,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] socket_path: &str,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
#[cfg(not(feature = "tunnel"))]
{
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
#[cfg(feature = "tunnel")]
{
let Some(surface) = self.tunnel.as_ref() else {
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
let Some(acceptor) = surface.acceptors.streamlocal_acceptor() else {
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
if !surface
.open_cap
.admit(surface.limits.max_concurrent_channels)
{
observe::decline_open(
"forwarded-streamlocal",
"cap-exceeded",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
tokio::spawn(handoff_forwarded_streamlocal(
acceptor,
Arc::clone(&surface.open_cap),
channel,
reply,
socket_path.to_owned(),
));
Ok(())
}
}
async fn server_channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
_host_to_connect: &str,
_port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("direct-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
async fn server_channel_open_direct_streamlocal(
&mut self,
channel: Channel<Msg>,
_socket_path: &str,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("direct-streamlocal", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
/// A never-feature row (ADR-003 §5): x11 has no family seam, so
/// the decline is not even registry territory.
async fn server_channel_open_x11(
&mut self,
channel: Channel<Msg>,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("x11", "never-feature", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
/// A never-feature row (ADR-003 §5): agent forwarding is the
/// anti-pattern of the vault custody model, always declined.
async fn server_channel_open_agent_forward(
&mut self,
channel: Channel<Msg>,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("agent", "never-feature", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
/// Wire-reachable only if
/// [`russh::client::Handler::should_accept_unknown_server_channel`]
/// ever answers `true` (it does not; the skeleton's default is
/// fail-closed); the defensive reject keeps the posture honest
/// even then.
async fn server_channel_open_unknown(
&mut self,
channel: Channel<Msg>,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("unknown", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
}
/// The bare-hook observation surface for the contract tests (the
/// `SessionHandler` §Test-surface shape, client-side): the rows call
/// the hooks directly where the wire cannot reach a hook on its own.
#[cfg(test)]
mod tests {
use russh::client::Handler as _;
use std::sync::Arc;
use super::ClientHandler;
use crate::host_key::HostKeyPolicy;
/// - *Contract stamp:* ADR-004 decision 6 posture mirror (the
/// client-side: the policy is injected, the handler never
/// decides trust), pinned at the dial wiring; here, that the
/// labels ride the construction so the policy sees
/// `PresentedHostKey { host, port }` verbatim, and the
/// fingerprint rides the crate's single fingerprint path.
#[tokio::test]
async fn labels_ride_the_construction_into_the_policy_hook() {
use crate::host_key::PresentedHostKey;
use async_trait::async_trait;
struct Recording {
seen: parking_lot::Mutex<Vec<(String, String, u16)>>,
}
#[async_trait]
impl HostKeyPolicy for Recording {
async fn decide(&self, presented: &PresentedHostKey) -> bool {
self.seen.lock().push((
presented.host.clone(),
presented.fingerprint.clone(),
presented.port,
));
false
}
}
const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key");
let expected_fp = crate::fingerprint(
russh::keys::decode_secret_key(ALPHA_PEM, None)
.expect("fixture PEM decodes")
.public_key(),
);
let policy = Arc::new(Recording {
seen: parking_lot::Mutex::new(Vec::new()),
});
let mut handler = ClientHandler::new(
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
"runner.example",
2222,
);
let key = russh::keys::decode_secret_key(ALPHA_PEM, None)
.expect("fixture PEM decodes")
.public_key()
.clone();
let verdict = handler
.check_server_key(&russh::keys::PublicKeyOrCertificate::from(key))
.await
.expect("the hook completes");
let seen = policy.seen.lock().clone();
assert!(!verdict, "the injected policy's verdict rides through");
assert_eq!(
seen,
vec![("runner.example".to_string(), expected_fp, 2222)]
);
}
}