//! 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, limits: Limits, open_cap: std::sync::Arc, } /// 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, 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, } 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, host: impl Into, 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, 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 { 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, 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, #[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, #[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, _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, _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, _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, 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, 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>, } #[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, "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)] ); } }