Files
alkssh/src/client.rs
T
glm-5.3-flash dec644e653 feat(client): land the lifecycle primitives — listener handles + the clean-disconnect boundary (w4-lifecycle)
- src/client_accept.rs: the ReverseListener handles (ReverseEndpoint
  carried; cancel() the wire-honest teardown) returned by the -R listen
  rows; the shared cancel paths both the handles and the stateless wire
  forms compose into; the confirmed cancel releases the listener count
  exactly once; no Drop hides a wire op
- src/client.rs: the SshConnection internals behind the Arc
  (ConnectionShared) — the handles' independent cancels compose on the
  same stack; the §Lifecycle posture module docs (primitives-not-policy,
  OQ-12; the abrupt-drop/clean-close facts, row-pinned)
- rows: drop-handle no-wire-state, handle-cancel + budget release,
  cancel-mid-data-leg (tunnel build) + the every-build abrupt-drop row;
  existing reverse rows adapted to the handles; the unix cancel rides
  the handle flavor
- docs/architecture/client.md: the §Lifecycle primitives landing block;
  task file filled

Verification: cargo test (default / session / git / tunnel / all-features)
all green; clippy ×2 -D warnings clean; fmt clean; cargo doc --no-deps clean
2026-10-29 17:42:29 +00:00

477 lines
19 KiB
Rust

//! 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 connection handle's
//! clean-close path is [`SshConnection::close`]; the listener handles'
//! surface is [`crate::client_accept`]'s.
//!
//! ## Lifecycle posture (`w4-lifecycle` — the OQ-12 resolution in code)
//!
//! The handle surface is **primitives, not policy** (reverse-runner
//! fact 4): reconnect-and-restore (rebinding persisted tunnel ports —
//! the alkminer flow) is *assembly* composition over these
//! primitives; the crate provides bound ports, handles, cancellation,
//! clean close, and never owns a reconnect/restore policy. Two
//! recorded facts pin the explicit-vs-drop boundary:
//!
//! - **Drop is never the clean path.** At this russh pin dropping the
//! last wire-stack handle ends the runner and the transport goes
//! down **abruptly** — the peer observes a broken session (its
//! serving outcome is an error wire class), never the clean
//! disconnect message. So the surface never relies on drop timing
//! for clean state: only [`SshConnection::close`] sends
//! `Disconnect::ByApplication` — the peer observes a clean
//! disconnect, no half-open channels (row-pinned through the
//! server double, both directions of the boundary: abrupt-on-drop,
//! clean-on-close).
//! And a cancellation is async — it cannot hide inside a sync
//! `Drop` hook, so no handle hides a `Drop` side effect: the
//! listener handles' drop frees *nothing* on their wire state.
//! - **Every listener/handle is explicitly cancellable** — the `-R`
//! listens return the [`ReverseListener`](crate::client_accept::
//! ReverseListener) handles (the actually-bound endpoint carried;
//! `cancel()` the wire-honest teardown), the connection handle
//! carries [`SshConnection::close`]; the stateless wire forms
//! ([`SshConnection::reverse_listen_cancel`] + the unix twin)
//! mirror sshd's global-request cancels 1:1.
//!
//! ## 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;
#[cfg(feature = "tunnel")]
use crate::channel_cap::ChannelCap;
#[cfg(feature = "tunnel")]
use crate::client_accept::AcceptorRegistry;
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);
#[cfg(feature = "tunnel")]
let (handler, acceptors) = {
let acceptors = Arc::new(AcceptorRegistry::new());
(
handler.with_tunnel_surface(Arc::clone(&acceptors), config.limits),
acceptors,
)
};
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 {
shared: Arc::new(ConnectionShared {
handle,
limits: config.limits,
address: config.address,
username: config.username,
#[cfg(feature = "tunnel")]
acceptors,
#[cfg(feature = "tunnel")]
listener_budget: Arc::new(ChannelCap::default()),
}),
})
}
/// 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).
///
/// The russh handle, budgets and registry ride one shared inner
/// (behind the `Arc`) — the sharing the `client_accept` module's
/// listener handles need for their independent `cancel()` calls; the
/// wire-visible teardowns stay explicit calls on top of that shared
/// stack (the module docs' §Lifecycle posture: drop is never a
/// wire-visible act).
pub struct SshConnection {
shared: Arc<ConnectionShared>,
}
/// The connection handle's internals — the one behind the
/// [`SshConnection`]'s `Arc`, every field private to the crate: the
/// wire surface is the wrapped methods, never the russh stack itself
/// (`serve.rs`'s wrapped-surface pattern mirrored client-side).
///
/// The arc's meaning for the lifecycle boundary: the russh stack
/// (the runner) lives while *any* handle holds the arc — the
/// connection handle's drop is the abrupt teardown only when it is
/// the last holder (row-pinned), and a live `ReverseListener` (its
/// `cancel()` composes on this stack) keeps the wire serving until
/// its own explicit cancel. The clean teardown is
/// [`SshConnection::close`]'s, whatever holders exist.
pub(crate) struct ConnectionShared {
/// The russh handle the connection and the listener handles
/// compose into (its own request methods take `&self` at this
/// pin — the shared arc is the concurrent-sharing shape).
pub(crate) handle: russh::client::Handle<ClientHandler>,
pub(crate) limits: Limits,
pub(crate) address: String,
pub(crate) username: String,
/// The `-R` leg's acceptor registry (the dial constructs it empty;
/// the assembly installs/uninstalls across the live connection —
/// see [`SshConnection::acceptors`] and
/// [`crate::client_accept`]`'s recorded decisions.
#[cfg(feature = "tunnel")]
pub(crate) acceptors: Arc<AcceptorRegistry>,
/// The reverse-listen count's dedicated bound — admitted at the
/// listen rows, released at the confirmed cancels and the refused/
/// wire-error paths (the cap relation recorded in
/// [`crate::client_accept`]; [`crate::channel_cap`]'s mutex makes
/// the counts sound under the caller's task). Behind the arc so
/// the listener handles release it from their own tasks too.
#[cfg(feature = "tunnel")]
pub(crate) listener_budget: Arc<ChannelCap>,
}
impl SshConnection {
/// The bounded-resource budget the dial ran under (carried; the
/// later op rows' admission surface).
pub fn limits(&self) -> &Limits {
&self.shared.limits
}
/// The dialed address as the config carried it.
pub fn address(&self) -> &str {
&self.shared.address
}
/// The user the connection authenticated as.
pub fn username(&self) -> &str {
&self.shared.username
}
/// The `-R` leg's acceptor registry (the
/// `w4-reverse-listen` surface, `client_accept`'s module docs
/// carry the recorded decisions) — install/uninstall the
/// per-type acceptors across the live connection (the
/// late-install dynamic). The dial constructs it empty: nothing
/// server-initiated is served until a slot holds, fail-closed at
/// arrival time.
#[cfg(feature = "tunnel")]
pub fn acceptors(&self) -> &AcceptorRegistry {
&self.shared.acceptors
}
/// The reverse-listen count's shared handle — the op rows
/// ([`crate::client_accept`]) read it; private to the crate.
#[cfg(feature = "tunnel")]
pub(crate) fn listener_budget(&self) -> &ChannelCap {
&self.shared.listener_budget
}
/// The shared inner — the listener handles' compose-into point
/// (their cancels run against the same russh stack); private to
/// the crate.
#[cfg(feature = "tunnel")]
pub(crate) fn shared(&self) -> &Arc<ConnectionShared> {
&self.shared
}
/// The russh handle the op rows compose into (`w4-op-rows` — the
/// method bodies live in [`crate::client_ops`], a sibling module,
/// so the field stays private to this module).
#[cfg(any(feature = "session", feature = "git", feature = "tunnel"))]
pub(crate) fn russh_handle(
&self,
) -> &russh::client::Handle<crate::client_handler::ClientHandler> {
&self.shared.handle
}
/// The dial's bounded-resource budget, verbatim — the op rows
/// read the knobs they consume (the exec-command length cap
/// drives the command-carrying rows' client-mirror gate).
#[cfg(any(feature = "session", feature = "git"))]
pub(crate) fn budgets(&self) -> &Limits {
&self.shared.limits
}
/// Whether the underlying russh session is closed.
pub fn is_closed(&self) -> bool {
self.shared.handle.is_closed()
}
/// The explicit clean-close path (the lifecycle primitive,
/// [`russh::Disconnect::ByApplication`] — the honest close): the
/// peer observes a clean disconnect, no half-open channels —
/// every open channel is torn down with the session (row-pinned
/// through the server double). **Drop is not this path** (the
/// module docs' §Lifecycle posture): a dropped handle releases
/// nothing on the wire — the connection outlives the handle
/// until the peer or the wall clock ends it.
pub async fn close(self) {
let _ = self
.shared
.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 {
let mut debug = f.debug_struct("SshConnection");
debug
.field("address", &self.shared.address)
.field("username", &self.shared.username)
.field("limits", &self.shared.limits)
.field("closed", &self.shared.handle.is_closed());
#[cfg(feature = "tunnel")]
debug.field("acceptors", &self.shared.acceptors);
debug.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"
);
}
}