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:
1 parent
d300095687
commit
0d3fd48f5d
11 files changed
+1796
-31
No files matched your search
+340
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user