feat(client): land the dial surface — the authenticated connection handle + the fail-closed client handler skeleton (w4-dial)

The client direction's substrate (client.md §The dial surface), the
mirror of w2-server-mount: dial(DialConfig) runs the fail-closed
four-step sequence — decrypt-at-dial key load (every KeySource
posture a distinct no-dial outcome), the bounded russh client Config
from Limits (keepalive 15 s / 3, the wall clock always Some, window/
packet/buffer verbatim), the injected host-key policy at
check_server_key (RejectAll connects to nothing; UnknownKey maps to
DialError::HostKeyRejected — its only producer), and exactly one
publickey-only auth attempt — and returns the authenticated
SshConnection handle everything else in the wave rides.

- DialConfig amendment (the sanctioned course-correction, recorded):
  the required `username` field — the USERAUTH_REQUEST's user string;
  no crate default, never identity, never logged (ADR-004 decision 5).
- The client Handler skeleton (src/client_handler.rs): every
  server_channel_open_* hook overridden explicit-over-inherited with
  a clean AdministrativelyProhibited reject (never russh's
  accept-and-drop default, ADR-003 §4); should_accept_unknown_
  server_channel the explicit false; decline events on the crate
  vocabulary (no-acceptor / never-feature reasons).
- The in-process fake sshd (tests/common/sshd_double.rs): the
  client-direction contract vehicle — a plain russh server on a real
  loopback TCP listener, accept-set identity resolution, the auth_
  succeeded handle carrier for the server-initiated-open rows, the
  per-session outcome channel for the clean-close rows.
- tests/client_dial.rs: 10 contract rows — dial success, the five
  key-source no-dial postures, RejectAll / pin-mismatch / case-2
  policy rows, refused auth, the malformed-address dial-time refusal,
  the bounded-config observable, the six-hook decline wire rows with
  decline events, and the smoke: alkssh-client ↔ alkssh-server over a
  real TCP loopback (real-sshd dial stays wave-5 e2e).

Verification: cargo test + per-feature + --all-features (14 binaries,
all green), cargo clippy ×2 (-D warnings), cargo fmt --check,
cargo doc --no-deps — all clean. taskgraph validate: 35 tasks valid.
This commit is contained in:
glm-5.3-flash committed 2026-10-29 14:07:15 +00:00
1 parent d300095687
commit 0d3fd48f5d
11 files changed
+1796 -31

No files matched your search

+340
View File
@@ -0,0 +1,340 @@
//! The dial surface (`w4-dial`) — the door *outward* (`client.md`
//! §The dial surface), the client-direction mirror of
//! [`crate::adapter`]'s mount: the russh client stack every wave-4 op
//! row composes into.
//!
//! The entry ([`dial`]) takes the wave-1 [`DialConfig`] and runs the
//! whole authenticated-dial sequence, **fail-closed at every step**:
//!
//! 1. **The decrypt-at-dial key load** (ADR-005 decision 2): the
//! injected [`KeySource::load`] runs *here and nowhere else* —
//! locked / absent / decryption / unknown-version / decode are all
//! **no-dial outcomes** ([`DialError::KeySource`], the variant
//! preserved). The decoded key lives for this dial; the vault is
//! needed only while the load runs (locking mid-session keeps
//! established connections streaming, POC-2 §2).
//! 2. **The bounded russh client `Config`** ([`russh_client_config`])
//! from the [`Limits`]: the keepalive 15 s / 3 posture
//! (`reverse-runner.md` lesson 3 — OpenSSH
//! `ServerAliveInterval=15`/`CountMax=3` parity), the wall clock
//! **always `Some`** (`inactivity_timeout` — the crate never
//! constructs a session with no clock), and the window/packet/
//! channel-buffer knobs verbatim. Everything else stays russh's
//! own default posture (the ADR-002 surface notes, mirrored from
//! [`crate::adapter`]'s server mapping: rekey `Limits`
//! russh-enforced, preferred algorithms russh's — the ed25519-only
//! posture holds by the loaded key set itself). No client-side
//! auth-attempt knob exists at this pin and none is needed: the
//! family contract is exactly **one publickey attempt**.
//! 3. **The policy call at `check_server_key`** (OQ-05) — the
//! wave-1 [`HostKeyPolicy`](crate::HostKeyPolicy) seam consumed by
//! the handler ([`crate::client_handler`]): the crate default
//! [`RejectAll`](crate::RejectAll) connects to nothing; a
//! pin-mismatch hard-declines; a case-2 assembly policy decides.
//! Declines surface as [`DialError::HostKeyRejected`].
//! 4. **Publickey-only authentication** (the family contract):
//! exactly one `authenticate_publickey` attempt from the loaded
//! key — no password, no keyboard-interactive, no cert, no retry,
//! no fallback method (russh's handle methods for those are never
//! exposed through [`SshConnection`]); a refusal is
//! [`DialError::AuthRejected`] and the attempt is closed cleanly.
//!
//! **The connection handle** ([`SshConnection`]) is the type
//! everything else in the wave rides (the op rows open channels on
//! it; the `-R` leg installs the acceptor registry over it) — the
//! russh `client::Handle` wrapped with the crate's surface, the
//! policy, limits and endpoint labels carried. The full
//! cancellation/clean-close lifecycle surface is `w4-lifecycle`'s;
//! this task lands the raw material (the explicit [`SshConnection::
//! close`] path; the handle primitives below it).
//!
//! No reconnect or restore policy is owned by the crate (OQ-12):
//! failures are failures ([`DialError`]); reconnection strategy is
//! assembly composition over these primitives.
//!
//! ## Test surface
//!
//! Two mechanics are re-exported `#[doc(hidden)]` (the `serve.rs`
//! §Test-surface pattern) for the wire contract rows:
//! [`russh_client_config`] (the bounded-knob mapping, asserted
//! verbatim) and [`ClientHandler`](crate::client_handler::ClientHandler)
//! (the handler skeleton; `client_handler.rs`). The wire vehicle is
//! the in-process fake `sshd` in `tests/common/sshd_double.rs` — the
//! client direction dials vanilla servers, so the loopback harness
//! (the *server*-direction vehicle) only hosts the crate-to-crate
//! smoke (see the task's smoke-posture record).
use std::sync::Arc;
use russh::Disconnect;
use russh::keys::PrivateKeyWithHashAlg;
use crate::client_handler::ClientHandler;
use crate::config::{DialConfig, Limits};
use crate::error::DialError;
/// The authenticated dial — the client direction's entry point
/// (`client.md` §The dial surface): see the module docs for the
/// four-step sequence and their fail-closed outcomes.
pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> {
let key = config.key_source.load().await?;
let (host, port) = parse_dial_address(&config.address)?;
let handler = ClientHandler::new(Arc::clone(&config.host_key_policy), host, port);
let russh_config = Arc::new(build_client_config(&config.limits));
let mut handle = russh::client::connect(russh_config, config.address.as_str(), handler).await?;
let result = handle
.authenticate_publickey(
config.username.as_str(),
PrivateKeyWithHashAlg::new(Arc::new(key), None),
)
.await?;
if !result.success() {
let _ = handle
.disconnect(Disconnect::ByApplication, "authentication refused", "en")
.await;
return Err(DialError::AuthRejected);
}
Ok(SshConnection {
handle,
limits: config.limits,
address: config.address,
username: config.username,
})
}
/// Parses the dial address into its endpoint labels — the
/// [`PresentedHostKey`](crate::host_key::PresentedHostKey)'s
/// `host`/`port` decision context. Malformed targets refuse before
/// any connection ([`DialError::Address`]): plain `host:port` and the
/// bracket form `[host]:port` (literal IPv6) are the grammar.
fn parse_dial_address(address: &str) -> Result<(String, u16), DialError> {
let (host, port) = if let Some(rest) = address.strip_prefix('[') {
let (host, rest) = rest
.split_once(']')
.ok_or_else(|| DialError::Address(address.to_string()))?;
let port = rest
.strip_prefix(':')
.ok_or_else(|| DialError::Address(address.to_string()))?;
(host, port)
} else {
address
.rsplit_once(':')
.ok_or_else(|| DialError::Address(address.to_string()))?
};
if host.is_empty() {
return Err(DialError::Address(address.to_string()));
}
let port = port
.parse::<u16>()
.map_err(|_| DialError::Address(address.to_string()))?;
Ok((host.to_string(), port))
}
/// The [`Limits`] → russh client `Config` mapping — the bounded knobs
/// verbatim, the wall clock always on, everything else at russh's own
/// default posture (see the module docs). The mapping is the dial
/// task's `bounded-config wiring observable`;
/// `#[doc(hidden)]` re-export from the crate root.
pub(crate) fn build_client_config(limits: &Limits) -> russh::client::Config {
russh::client::Config {
window_size: limits.window_size,
maximum_packet_size: limits.maximum_packet_size,
channel_buffer_size: limits.channel_buffer_size,
inactivity_timeout: Some(limits.inactivity_timeout),
keepalive_interval: limits.keepalive_interval,
keepalive_max: limits.keepalive_max,
..russh::client::Config::default()
}
}
#[doc(hidden)]
pub fn russh_client_config(limits: &Limits) -> russh::client::Config {
build_client_config(limits)
}
/// The authenticated connection handle — the type every later
/// wave-4 op rides (`client.md`: "the authenticated connection
/// handle"): the russh `client::Handle` wrapped with the crate's
/// surface. The remote end is authenticated (publickey-only, single
/// attempt); the dial key is gone (its lifetime is the dial's —
/// everything after rides the wire, never the key).
pub struct SshConnection {
handle: russh::client::Handle<ClientHandler>,
limits: Limits,
address: String,
username: String,
}
impl SshConnection {
/// The bounded-resource budget the dial ran under (carried; the
/// later op rows' admission surface).
pub fn limits(&self) -> &Limits {
&self.limits
}
/// The dialed address as the config carried it.
pub fn address(&self) -> &str {
&self.address
}
/// The user the connection authenticated as.
pub fn username(&self) -> &str {
&self.username
}
/// Whether the underlying russh session is closed.
pub fn is_closed(&self) -> bool {
self.handle.is_closed()
}
/// The explicit clean-close path (OQ-12's raw material; the full
/// cancellable-listener lifecycle surface is `w4-lifecycle`'s):
/// `Disconnect::ByApplication`, the honest close — the peer
/// observes a clean disconnect, no half-open channels.
pub async fn close(self) {
let _ = self
.handle
.disconnect(Disconnect::ByApplication, "closed by application", "en")
.await;
}
}
impl std::fmt::Debug for SshConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshConnection")
.field("address", &self.address)
.field("username", &self.username)
.field("limits", &self.limits)
.field("closed", &self.handle.is_closed())
.finish()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use russh::keys::PrivateKey;
use super::{build_client_config, parse_dial_address};
use crate::config::{DialConfig, Limits};
use crate::error::{DialError, KeySourceError};
use crate::host_key::RejectAll;
use crate::key_source::KeySource;
struct NoKeySource;
#[async_trait::async_trait]
impl KeySource for NoKeySource {
async fn load(&self) -> Result<PrivateKey, KeySourceError> {
Err(KeySourceError::Absent)
}
}
/// - *Contract stamp:* `client.md` §Correctness facts (the
/// keepalive posture) + `overview.md` §Bounded-resources table —
/// the defaults land in the russh mapping verbatim.
#[test]
fn bounded_config_mapping_carries_the_default_postures() {
let config = build_client_config(&Limits::default());
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.inactivity_timeout,
Some(crate::config::DEFAULT_INACTIVITY_TIMEOUT),
"the wall clock is always set — no unbounded session"
);
assert_eq!(
config.keepalive_interval,
Some(Duration::from_secs(15)),
"the 15 s keepalive posture is the dial's default"
);
assert_eq!(config.keepalive_max, 3);
}
/// - *Contract stamp:* the [`Limits`] tunability contract (every
/// knob is the assembly's to tune; nothing is crate-fixed).
#[test]
fn bounded_config_mapping_is_tunable_one_for_one() {
let limits = Limits {
window_size: 1_024,
maximum_packet_size: 4_096,
channel_buffer_size: 7,
inactivity_timeout: Duration::from_secs(1_337),
keepalive_interval: None,
keepalive_max: 9,
..Limits::default()
};
let config = build_client_config(&limits);
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.inactivity_timeout, Some(Duration::from_secs(1_337)));
assert_eq!(config.keepalive_interval, None);
assert_eq!(config.keepalive_max, 9);
}
#[test]
fn dial_address_plain_form_parses() {
let (host, port) = parse_dial_address("runner.example:22").expect("parses");
assert_eq!(host, "runner.example");
assert_eq!(port, 22);
let (host, port) = parse_dial_address("127.0.0.1:4022").expect("parses");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 4022);
}
#[test]
fn dial_address_bracket_form_parses() {
let (host, port) = parse_dial_address("[2001:db8::1]:2222").expect("parses");
assert_eq!(host, "2001:db8::1");
assert_eq!(port, 2222);
}
#[test]
fn dial_address_malformed_forms_refuse() {
for bad in [
"runner.example",
":22",
"[]:22",
"host:port",
"[::1:2]x",
"",
] {
let err = parse_dial_address(bad).expect_err("refuses");
assert!(
matches!(err, DialError::Address(ref given) if *given == bad),
"expected Address, got {err:?}"
);
}
}
#[test]
fn dial_address_out_of_range_port_refuses() {
let err = parse_dial_address("host:99999").expect_err("refuses");
assert!(matches!(err, DialError::Address(_)));
}
#[tokio::test]
async fn key_source_refusal_surfaces_the_variant_through_the_config() {
let config = DialConfig::new(
"runner.example:22",
"runner",
Arc::new(NoKeySource),
Arc::new(RejectAll),
);
assert!(
matches!(config.key_source.load().await, Err(KeySourceError::Absent)),
"the no-dial outcome's variant is preserved through the config"
);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! 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`] — 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 and answers
//! [`ChannelOpenFailure::AdministrativelyProhibited`] — a clean
//! wire-visible refusal the peer observes as an
//! unconfirmed open, never an accepted-then-dropped channel. 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). The acceptor registry
//! that serves these opens under its feature is
//! `w4-reverse-listen`'s surface; this skeleton pins the decline-all
//! default every unregistered open gets.
//!
//! Data-plane hooks stay russh's defaults: every server-initiated
//! channel is declined, so no data for one can ever arrive. The
//! `w4-op-rows` / `w4-reverse-listen` tasks grow this skeleton in
//! place (the wave's sequential client chain); the decline events ride
//! the crate's [`crate::observe`] vocabulary (`decline_open`,
//! reason `no-acceptor`).
use russh::ChannelOpenFailure;
use russh::client::ChannelOpenHandle;
use russh::client::Msg;
use russh::keys::PublicKeyOrCertificate;
use russh::{Channel, ChannelId};
use crate::host_key::{HostKeyPolicy, PresentedHostKey};
use crate::observe;
use crate::{DialError, fingerprint};
/// 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,
}
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,
}
}
}
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(())
}
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: Channel<Msg>,
_connected_address: &str,
_connected_port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
async fn server_channel_open_forwarded_streamlocal(
&mut self,
channel: Channel<Msg>,
_socket_path: &str,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
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 — 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 std::sync::Arc;
use russh::client::Handler as _;
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)]
);
}
}
+65 -14
View File
@@ -15,8 +15,9 @@
//! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values,
//! and the [`Limits`].
//! - [`DialConfig`] — the client direction's dial configuration
//! (`client.md` §The dial surface): address, key source, host-key
//! policy, and the bounded-resource knobs (carried by [`Limits`]).
//! (`client.md` §The dial surface): address, `username`, key source,
//! host-key policy, and the bounded-resource knobs (carried by
//! [`Limits`]).
//!
//! Crate rules this module encodes:
//!
@@ -579,19 +580,41 @@ impl std::fmt::Debug for ServeConfig {
}
/// The client direction's dial configuration (`client.md` §The dial
/// surface): everything wave-4's dial loop takes at construction.
/// Pure data + injected handles — no russh `Config` is built here
/// (wave 2), no dial happens (wave 4).
/// surface): everything wave-4's dial surface takes. Pure data +
/// injected handles — the russh client `Config` construction and the
/// dial itself live in the crate's dial module (the `w4-dial`
/// surface).
///
/// Construction carries no validation: the key source's own
/// load path is fail-closed ([`KeySource`] — locked/absent/decryption
/// failures are all no-dial outcomes), and the host-key policy's
/// default is fail-closed ([crate::host_key::RejectAll]).
///
/// **The `username` field** (the task's sanctioned
/// `w3-seam-config`-pattern course-correction — the only amendment the
/// wave-1 shape needed): the USERAUTH_REQUEST's user string. The
/// wave-1 shape carried everything `client.md` §The dial surface names
/// but structurally missed this one: russh's `authenticate_publickey`
/// requires a user, and the remote end evaluates it — the family
/// identity model stays fingerprint-only forever (`auth.md` §Host-key;
/// the username is not identity and carries no routing semantics,
/// OQ-03), so this field is pure dial data the assembly supplies. No
/// crate default exists (a default account would be deployment policy
/// smuggled into the crate); an empty string is accepted as data and
/// is the remote end's to refuse.
pub struct DialConfig {
/// The dial target — `host:port` as russh's connect surface
/// takes it (the reverse-runner shape: vanilla `sshd` on the
/// fleet box).
/// fleet box). The same address labels the host-key policy's
/// presented-key decisions (host = the parsed host, port = the
/// parsed port).
pub address: String,
/// The user the dial authenticates as, verbatim on the
/// USERAUTH_REQUEST — not identity (the fingerprint is; ADR-004
/// decision 5's posture, mirrored client-side), never logged (the
/// crate's event vocabulary carries no username). See the type
/// docs.
pub username: String,
/// The key source the dial authenticates with (the wave-1
/// [`KeySource`] seam) — never a static key, never env vars;
/// decrypt-at-dial, fail-closed per ADR-005.
@@ -609,18 +632,20 @@ pub struct DialConfig {
}
impl DialConfig {
/// Builds a dial-config from the address and the two injected
/// handles the dial direction can never default — the key source
/// and the host-key policy. `limits` starts at
/// [`Limits::default()`] (keepalive 15 s / 3 posture riding
/// inside).
/// Builds a dial-config from the address, the user it
/// authenticates as, and the two injected handles the dial
/// direction can never default — the key source and the host-key
/// policy. `limits` starts at [`Limits::default()`] (keepalive
/// 15 s / 3 posture riding inside).
pub fn new(
address: impl Into<String>,
username: impl Into<String>,
key_source: Arc<dyn KeySource>,
host_key_policy: Arc<dyn HostKeyPolicy>,
) -> Self {
Self {
address: address.into(),
username: username.into(),
key_source,
host_key_policy,
limits: Limits::default(),
@@ -632,6 +657,7 @@ impl std::fmt::Debug for DialConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DialConfig")
.field("address", &self.address)
.field("username", &"<present>")
.field("key_source", &"<dyn KeySource>")
.field("host_key_policy", &"<dyn HostKeyPolicy>")
.field("limits", &self.limits)
@@ -963,7 +989,12 @@ mod tests {
#[test]
fn dial_config_defaults_carry_the_keepalive_posture() {
let dial = DialConfig::new("runner.example:22", key_source(), Arc::new(RejectAll));
let dial = DialConfig::new(
"runner.example:22",
"runner",
key_source(),
Arc::new(RejectAll),
);
assert_eq!(dial.address, "runner.example:22");
assert_eq!(dial.limits, Limits::default());
@@ -980,10 +1011,25 @@ mod tests {
let keys = key_source();
let policy: Arc<dyn crate::host_key::HostKeyPolicy> = Arc::new(RejectAll);
let dial = DialConfig::new("fleet.example:22", Arc::clone(&keys), Arc::clone(&policy));
let dial = DialConfig::new(
"fleet.example:22",
"deploy",
Arc::clone(&keys),
Arc::clone(&policy),
);
assert!(Arc::ptr_eq(&keys, &dial.key_source));
assert!(Arc::ptr_eq(&policy, &dial.host_key_policy));
assert_eq!(dial.address, "fleet.example:22");
assert_eq!(dial.username, "deploy");
}
/// an empty string is the assembly's datum (never a crate
/// default).
#[test]
fn dial_config_username_is_data_not_a_crate_default() {
let dial = DialConfig::new("fleet.example:22", "", key_source(), Arc::new(RejectAll));
assert_eq!(dial.username, "");
}
// The wave-3 per-feature seam handles: the construction shapes, the
@@ -1115,7 +1161,12 @@ mod tests {
#[tokio::test]
async fn dial_config_key_source_stays_fail_closed_through_the_config() {
let dial = DialConfig::new("fleet.example:22", key_source(), Arc::new(RejectAll));
let dial = DialConfig::new(
"fleet.example:22",
"deploy",
key_source(),
Arc::new(RejectAll),
);
let err = dial.key_source.load().await.expect_err("no key source");
assert!(matches!(err, KeySourceError::Absent));
+137 -1
View File
@@ -156,9 +156,84 @@ pub enum AdapterError {
Wire(#[from] russh::Error),
}
/// The dial surface's failure classes (the client direction's error
/// counterpart of [`AdapterError`]) — deliberately closed, every
/// variant a no-connection outcome (ADR-005 decision 4: failures are
/// fail-closed and distinct, never collapsed, never a fallback dial):
///
/// - **KeySource** — the decrypt-at-dial load refused. The
/// [`KeySourceError`] variant is preserved verbatim (locked / absent
/// / decryption / unknown-version / decode each stays itself — the
/// ADR-005 decision 4 matrix read through the dial seam). No key
/// material, no dial.
/// - **Address** — the dial target is not a parseable `host:port`.
/// Checked at dial time (the config carries no construction-time
/// validation, the family contract); a malformed target is a
/// refusal before any connection.
/// - **HostKeyRejected** — the injected host-key policy
/// ([`crate::HostKeyPolicy`]) declined the presented server key at
/// `check_server_key` (a `false` verdict is the decline
/// disposition; russh surfaces it as its `UnknownKey` wire error,
/// which this crate maps here because that hook is the only
/// producer). The trust decision is the policy's — its own record
/// (its decision log) carries the reasons; the crate never
/// second-guesses or retries.
/// - **AuthRejected** — the remote end refused the publickey
/// authentication (the family contract is exactly one
/// publickey-only attempt; there is no retry, no fallback method).
/// - **Wire** — the russh session stack's own error, mapped
/// transparently, including the dial's connect/resolve/IO classes.
#[derive(Debug, Error)]
pub enum DialError {
/// The dial key's decrypt-at-dial load refused — every
/// [`KeySourceError`] variant is a no-dial outcome, preserved
/// un-collapsed (ADR-005 decision 4).
#[error("dial key source refused: {0}")]
KeySource(#[from] KeySourceError),
/// The dial target is not a parseable `host:port` address — the
/// wire never dialed. The string carries the address as given.
#[error("dial target is not a usable host:port address: {0}")]
Address(String),
/// The host-key policy declined the presented server key — the
/// dial aborts (a disposition the dial cannot proceed on; the
/// crate default [`RejectAll`](crate::RejectAll) policy makes
/// every dial refuse, by design).
#[error("host-key policy declined the presented server key")]
HostKeyRejected,
/// The remote end refused the publickey authentication — the
/// family's one-attempt publickey-only posture (no retries, no
/// fallback methods, never another key).
#[error("authentication was refused by the remote end")]
AuthRejected,
/// A russh session transport error (connect / KEX / IO class).
/// The `From<russh::Error>` conversion below is the variant's
/// only mapping (it carries the one semantic upgrade, see its
/// docs), so no `#[from]` lives on the field.
#[error("ssh dial wire error: {0}")]
Wire(russh::Error),
}
impl From<russh::Error> for DialError {
/// The transparent russh mapping with one semantic upgrade:
/// `UnknownKey` is the wire shape of a host-key policy decline
/// (the client's `check_server_key` hook is the only producer),
/// so it becomes [`DialError::HostKeyRejected`] — every other
/// error stays the transparent [`DialError::Wire`] class.
fn from(error: russh::Error) -> Self {
match error {
russh::Error::UnknownKey => Self::HostKeyRejected,
other => Self::Wire(other),
}
}
}
#[cfg(test)]
mod tests {
use super::{AdapterError, KeySourceError};
use super::{AdapterError, DialError, KeySourceError};
fn all_variants() -> [KeySourceError; 5] {
[
@@ -224,6 +299,67 @@ mod tests {
}
}
fn all_dial_variants() -> Vec<DialError> {
let mut all: Vec<DialError> = all_variants()
.into_iter()
.map(DialError::KeySource)
.collect();
all.push(DialError::Address("not an address".into()));
all.push(DialError::HostKeyRejected);
all.push(DialError::AuthRejected);
all.push(DialError::Wire(russh::Error::Disconnect));
all
}
#[test]
fn dial_error_closed_taxonomy_no_catch_all() {
let exhaustive: fn(&DialError) -> &'static str = |e| match e {
DialError::KeySource(_) => "key-source",
DialError::Address(_) => "address",
DialError::HostKeyRejected => "host-key-rejected",
DialError::AuthRejected => "auth-rejected",
DialError::Wire(_) => "wire",
};
for v in all_dial_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
#[test]
fn dial_error_key_source_variants_preserve_the_class() {
for source in all_variants() {
let wrapped = DialError::KeySource(source.clone());
match wrapped {
DialError::KeySource(seen) => {
assert_eq!(seen.to_string(), source.to_string())
}
other => panic!("unexpected variant {other:?}"),
}
}
}
/// The host-key policy's `false` verdict is russh's `UnknownKey`
/// wire error — the client's `check_server_key` hook is the only
/// producer, so the transparent mapping upgrades exactly that one
/// class and leaves everything else `Wire`.
///
/// - *Contract stamp:* `host_key.rs` (the verdict type pinned to
/// the hook shape); `client.rs` (the dial surface's decline
/// class).
#[test]
fn dial_error_unknown_key_maps_to_host_key_rejected() {
let mapped: DialError = russh::Error::UnknownKey.into();
assert!(
matches!(mapped, DialError::HostKeyRejected),
"expected HostKeyRejected, got {mapped:?}"
);
let mapped: DialError = russh::Error::Disconnect.into();
assert!(
matches!(mapped, DialError::Wire(russh::Error::Disconnect)),
"expected wire-transparent mapping, got {mapped:?}"
);
}
fn all_adapter_variants() -> Vec<AdapterError> {
let mut all: Vec<AdapterError> = all_variants()
.into_iter()
+17 -5
View File
@@ -19,13 +19,16 @@
//! rest, decrypted at use (ADR-005), never env vars.
//!
//! Architecture: `docs/architecture/` (phase 1 — ADRs 001–006 accepted;
//! the foundations, the server stack, and the server-direction mappings
//! are landed through wave 3; the client direction, the validation
//! suite, and the release are later implementation waves). See also
//! `docs/research/`, `docs/plans/implementation.md`, and family ADR-003.
//! the foundations, the server stack, the server-direction mappings, and
//! the client direction's dial surface are landed through wave 4's dial
//! task; the client op rows, the validation suite, and the release are
//! later implementation waves). See also `docs/research/`,
//! `docs/plans/implementation.md`, and family ADR-003.
mod adapter;
mod channel_cap;
mod client;
mod client_handler;
mod config;
mod dispatch;
mod error;
@@ -43,6 +46,15 @@ mod tty_map;
mod tunnel_map;
pub use adapter::SshAdapter;
/// Contract-test surface (`#[doc(hidden)]`): the dial surface's
/// config-mapping and handler-skeleton mechanics (the `serve.rs`
/// §Test-surface mirror; see `client.rs` §Test surface). Not
/// documented API.
#[doc(hidden)]
pub use client::russh_client_config;
pub use client::{SshConnection, dial};
#[doc(hidden)]
pub use client_handler::ClientHandler;
#[cfg(feature = "git")]
pub use config::GitSeam;
#[cfg(feature = "session")]
@@ -50,7 +62,7 @@ pub use config::SessionSeam;
#[cfg(feature = "tunnel")]
pub use config::TunnelListenSeam;
pub use config::{DialConfig, Limits, ServeConfig};
pub use error::{AdapterError, KeySourceError};
pub use error::{AdapterError, DialError, KeySourceError};
pub use fingerprint::fingerprint;
/// Contract-test surface (`#[doc(hidden)]`): the per-connection auth
/// bridge handler — constructible so the tests drive its hooks directly
+5
View File
@@ -106,6 +106,11 @@
//! not-found pair) — the global-request and arrival rows, and on the
//! auth class `"method-unimplemented"` / `"cert-unsupported"` /
//! `"non-ed25519"` / `"fingerprint-unresolved"`.
//! Client-side (the dial handler's `server_channel_open_*`
//! skeleton, `w4-dial`): `"no-acceptor"` (the decline-all default —
//! a server-initiated open with no registered acceptor; the
//! registry is `w4-reverse-listen`'s) and `"never-feature"` (the
//! x11/agent rows — ADR-003 §5).
//! - `exec_route` — only on `exec_request`-row events: the dispatch
//! route the parse routed to (`"git"` | `"pipe"`), so a git row's
//! decline is distinguishable from a pipe row's in combined builds.