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.
475 lines
18 KiB
Rust
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)]
|
|
);
|
|
}
|
|
}
|