feat(client): land the -R leg — the acceptor registry + the reverse-listen rows (w4-reverse-listen)

- src/client_accept.rs: the assembly-mutable acceptor registry
  (per-type fixed slots under `tunnel`; refuse-never-replace install;
  payload = the `TunnelChannel` duplex + the open's echo/originator
  fields — the harness `ForwardedAcceptor` vocabulary grown), the
  reverse-listen rows (bound-port surfaced; the listen/cancel forms;
  the runtime-Unix gate at the listen), and the accepted-opens'
  spawned handoff tasks.
- src/client_handler.rs: the `forwarded-tcpip` /
  `forwarded-streamlocal` hooks route through the registry at
  arrival time — registered + admitted → accept + handoff;
  unregistered/unfeatured/cap-exhausted → the clean
  `AdministrativelyProhibited` decline (never accept-and-drop);
  pre-install arrivals decline, never buffered.
- src/client.rs: the dial wires the empty registry into the handler
  + the connection (`SshConnection::acceptors`, the late-install
  accessor) + the listener budget's dedicated counter.
- src/error.rs: `OpError::NoAcceptor` / `ListenRefused` /
  `ListenCapExceeded`; `RequestDenied` → `ListenRefused` (the
  listen/cancel forms' wire decline class; the port-0 mint rides
  alone at this pin — an explicit port's bound *is* the requested
  one, surfaced).
- tests/common/sshd_double.rs: the fake `sshd`'s `-R` half — the
  listen/cancel hooks bind real sockets (the out-param mint is the
  real bound port), the accept loops open the server-initiated
  forwarded channels + pump per accepted connection, cancels match
  by the raw wire strings and tear the listener down.
- tests/client_reverse_listen.rs: the contract rows (listen + data
  leg, the mint, the pre-wire gates, the declines, late-install
  on/off, the cancels, the ceiling, the unix twins).
- tests/tunnel_mapping.rs: the full-circle client ↔ server-direction
  smoke (both `-R` legs through the alkcall fabric double); real-
  `sshd` `-R` stays wave-5 e2e (recorded).
- client.md: the acceptor's landing record (shape, cap relation,
  gates, unix-gate placement, port-0 parity).
- fix(w4-op-rows, test cfg): the test mod's `family_exit_code`
  import gated to the exit-consuming builds (`--features tunnel`
  did not compile its tests before).

Verification: cargo test (default / each feature / --all-features)
green; cargo clippy --all-targets -- -D warnings (none +
--all-features) clean; cargo fmt --check clean; cargo doc --no-deps
clean (default build; all-features carries only the pre-existing
client_ops private-link warnings).
This commit is contained in:
glm-5.3-flash committed 2026-10-29 17:19:24 +00:00
1 parent 2371c2e169
commit 7e5989f574
11 files changed
+2314 -54

No files matched your search

+33
View File
@@ -69,6 +69,39 @@ half-capability, which the family forbids (ADR-003 symmetry +
consumers turn capabilities on/off per connection; the shape is an
assembly-mutable acceptor registry).
> **The landed acceptor shape (`w4-reverse-listen` — recorded at
> landing):** the registry is **per-type fixed slots** — exactly the
> two `-R` data-leg types the client direction can serve
> (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`;
> no map, no generic key), one acceptor trait per type; install is
> **refuse-never-replace** (the `ListenRouter::register` recorded
> posture, mirrored client-side); the slots are read **at arrival
> time** by the dial handler's hooks — a pre-install/unfeatured open
> is declined at arrival, never buffered. The payload an accepted
> open hands the assembly: the channel duplex (family-typed — the
> `-L` rows' `TunnelChannel` byte duplex, one type both legs ride) +
> the open's echo/originator fields; **the pump is the acceptor's**
> from the handoff on (the family handler shape) — the crate keeps no
> accepted-open ledger. Bounded bookkeeping, the cap relation: the
> registry bounded by construction (two slots); the
> **reverse-listen count** and the **accepted-open handoff count**
> are two dedicated counters, each against the same
> `Limits::max_concurrent_channels` ceiling (the server-side
> listener/ListenerCap posture mirrored — independent counts, one
> ceiling; the listens live at the peer, the counter exists so a dial
> cannot mint an unbounded listener set). The **forward-request rows
> gate on the registered acceptor** (the issuing-side disposition:
> a listen whose data leg cannot serve is never asked for —
> install-first, `OpError::NoAcceptor` pre-wire), plus
> `OpError::ListenRefused` (the peer's `RequestDenied` — denied
> listens and unknown-listener cancels, sshd raw-string parity) and
> `OpError::ListenCapExceeded` (the ceiling, pre-wire). The
> **runtime-Unix gate** rides the unix *listen* rows only (the bind
> is the platform-bound op; the `forwarded-streamlocal` ingestion is
> platform-neutral byte pumping). The `tcpip_forward` reply's mint
> rides only for `port = 0` requests (russh/sshd parity: an explicit
> port's bound *is* the requested port, which the row surfaces).
## Lifecycle primitives (OQ-12 resolution)
- Forward listeners return the **actually-bound port** (russh
+54 -3
View File
@@ -69,6 +69,10 @@ 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;
@@ -81,6 +85,14 @@ pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> {
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?;
@@ -103,6 +115,10 @@ pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> {
limits: config.limits,
address: config.address,
username: config.username,
#[cfg(feature = "tunnel")]
acceptors,
#[cfg(feature = "tunnel")]
listener_budget: ChannelCap::default(),
})
}
@@ -167,6 +183,19 @@ pub struct SshConnection {
limits: Limits,
address: String,
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")]
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).
#[cfg(feature = "tunnel")]
listener_budget: ChannelCap,
}
impl SshConnection {
@@ -186,6 +215,25 @@ impl SshConnection {
&self.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.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.listener_budget
}
/// Whether the underlying russh session is closed.
pub fn is_closed(&self) -> bool {
self.handle.is_closed()
@@ -223,12 +271,15 @@ impl SshConnection {
impl std::fmt::Debug for SshConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshConnection")
let mut debug = f.debug_struct("SshConnection");
debug
.field("address", &self.address)
.field("username", &self.username)
.field("limits", &self.limits)
.field("closed", &self.handle.is_closed())
.finish()
.field("closed", &self.handle.is_closed());
#[cfg(feature = "tunnel")]
debug.field("acceptors", &self.acceptors);
debug.finish()
}
}
+624
View File
@@ -0,0 +1,624 @@
//! The `-R` leg's client half (`w4-reverse-listen` — `client.md`
//! §op rows row 4 + §The acceptor): the hub's reverse direction in
//! family types. Two surfaces, the acceptor registry first:
//!
//! 1. **The forward request rows** — [`SshConnection::reverse_listen`]
//! / [`SshConnection::reverse_listen_cancel`] / the unix pair
//! (`reverse_listen_unix` / `reverse_listen_unix_cancel`): the
//! vanilla-server listen requests, the lifecycle primitives
//! (`client.md` §Lifecycle — the actually-bound port surfaced,
//! every listener explicitly cancellable; the *handles*' surface is
//! `w4-lifecycle`'s growth).
//! 2. **The acceptor registry** ([`AcceptorRegistry`]) — the
//! assembly-mutable, per-channel-type registry the dial surface's
//! russh handler routes **server-initiated `forwarded-*` opens**
//! through. Served only under a registered acceptor for that type
//! and under the `tunnel` feature; unregistered/unfeatured accepted
//! opens **decline cleanly** — never russh's accept-and-drop
//! default (ADR-003 §4, the client-side fail-closed acceptor; the
//! reverse-runner fact 1 disposition is ours).
//!
//! ## The recorded decisions (the task's decide + record items)
//!
//! - **Registry shape: per-type fixed slots, not a typed map** — the
//! client direction's registry-servable type set is closed and tiny
//! (exactly the `-R` data leg's two: `forwarded-tcpip` /
//! `forwarded-streamlocal`, both under `tunnel`); one trait per type
//! ([`TcpipForwardedAcceptor`] / [`StreamlocalForwardedAcceptor`])
//! makes *which acceptor serves which type* a compile-time fact and
//! the fail-closed gate a one-slot read. A generic typed map would
//! add runtime dispatch (a typed key + an enum payload) for a set
//! that cannot grow without an ADR (any further server-initiated
//! type is a mapping, `client.md` §op rows). The harness's
//! `ForwardedAcceptor` vocabulary is grown, not forked: the payload
//! fields are the same shapes (the open's originator fields + the
//! channel duplex), the duplex now family-typed
//! ([`TunnelChannel`](crate::TunnelChannel) — the `-L` rows' byte
//! duplex, one type both legs ride).
//! - **Install is refuse-never-replace** ([`AcceptorRegistry::
//! install_tcpip`]'s held-slot error): the same posture recorded
//! server-side on the `ListenRouter` at `w4-router-collision` —
//! a same-slot second install is an assembly program error, declined
//! never a quiet displacement of a live acceptor. Uninstall is
//! explicit, 1:1 with the install.
//! - **Late-install across a live connection** (the alkminer
//! `install_forward_sink` dynamic — reverse-runner fact 2): the
//! registry rides the connected [`SshConnection`]
//! ([`SshConnection::acceptors`]) — the dial constructs it empty,
//! the assembly installs/uninstalls whenever it decides. The open
//! hooks read the slot **at arrival time**, fail-closed: an open
//! arriving before its acceptor installs (or after an uninstall)
//! is **declined at arrival, never buffered** — no unclaimed-open
//! queue exists anywhere in this module's posture (an unclaimed
//! open's bytes would have to be buffered unboundedly to accept-
//! then-route; the decline is the clean, bounded answer).
//! - **The listen rows gate on the registered acceptor** — the
//! *issuing*-side disposition: a listen whose data leg cannot serve
//! is a half-capability (the peer stays bound, every arrival
//! declines), so a `reverse_listen` with no registered acceptor for
//! the type answers
//! [`OpError::NoAcceptor`](crate::OpError) **before any wire op**
//! (install-first is the recorded order; the rows never buffer an
//! outstanding listen against a later install — re-issuing is the
//! assembly's retry, the listen rows are stateless global requests).
//! - **Bounded bookkeeping (AGENTS.md convention 9) — the cap
//! relation** (the server-side listener-counter posture as the
//! template): the registry is bounded by construction (two fixed
//! slots). The per-connection **listener count** is a dedicated
//! in-crate counter admitting at the listen rows and releasing at
//! the confirmed cancels and the failed/wire-error paths — bounded
//! against the *same* `Limits::max_concurrent_channels` ceiling the
//! server direction's listener counter uses (independent counts, one
//! ceiling — never a map: the outstanding listens live at the peer;
//! the counter exists so a dial cannot mint an unbounded listener
//! set the registry would have to serve). The per-connection
//! **accepted-open handoff count** is the second dedicated counter
//! against the same ceiling (admitted in the dial handler's open
//! hook, accepted-with-the-count-held, released when the acceptor's
//! `accept` call completes — no accepted-open ledger anywhere
//! else: the pump is the acceptor's from the handoff on, the
//! channel's transport bounds are russh's windows, and the slot read
//! is the arrival admission).
//! - **The runtime-Unix gate (OQ-11 symmetry)**: the streamlocal
//! *listen* rows (`reverse_listen_unix` / `reverse_listen_unix_cancel`)
//! compile everywhere and
//! decline off-Unix before any wire op (the bind is the
//! platform-bound operation — the door puts nothing on the wire it
//! cannot track). The `forwarded-streamlocal` *open* hook is
//! universal: ingestion is platform-neutral byte pumping — the
//! unix-socket leg's socket is the peer-side's.
//! - **`OpError::ListenRefused`** — the peer's `RequestDenied` on
//! every form's decline (a denied listen, a cancel of an unknown
//! listener) — channel-scoped honesty: the connection stays usable
//! (the `From` mapping's recorded producer, `error.rs`).
//!
//! ## The data leg
//!
//! Per accepted open: the hook accepts the channel and hands the
//! assembly the open — the [`TunnelChannel`](crate::TunnelChannel)
//! duplex + the open's echo/originator fields (the payload types
//! below) — on a spawned task (the spawn-and-return rule's client
//! mirror: the hook runs in the packet loop and returns fast). The
//! pump is the acceptor's from the handoff — the family handler shape
//! (alktunnels' `ForwardedHandler` recipe, POC-3 §C.4); dropping the
//! duplex is wire-clean (russh closes the channel).
//!
//! ## Test surface
//!
//! The wire vehicle is the fake `sshd` double grown with the
//! server-initiated-forward surfaces (`tests/common/sshd_double.rs`) —
//! the double binds **real** sockets for its script accepts, opens the
//! forwarded channels toward the client per accepted connection, and
//! pumps them (a real `ssh -R`-shaped round trip in-process, the task's
//! recorded smoke posture). The full-circle alkssh-client ↔
//! alkssh-server-direction smoke (both `-R` legs through the alkcall
//! fabric, the composition the assembly makes) rides the fabric
//! doubles in `tests/tunnel_mapping.rs` — the same doubles the
//! server-side `-R` rows use; real-`sshd` `-R` is wave-5 e2e
//! territory, never silently dropped.
//!
//! ## Smoke posture (recorded)
//!
//! The in-process smoke shape is a real `ssh -R`-shaped round trip: a
//! TCP connection to the forward's actually-bound port round-trips
//! through the registered acceptor's duplex (the reverse-runner's
//! hub-side proxy shape). The full-circle variant against the alkssh
//! server direction pins the client registry against the server
//! direction's real arrival pump (the echo/originator/mint shapes
//! `w3-tunnel-listen` landed); real-`sshd` `-R` is wave-5 e2e.
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::Mutex;
use russh::client::ChannelOpenHandle;
use crate::TunnelChannel;
use crate::channel_cap::ChannelCap;
use crate::client::SshConnection;
use crate::error::OpError;
use crate::observe;
/// An accepted server-initiated `forwarded-tcpip` open — the acceptor
/// registry's payload for the
/// [`TcpipForwardedAcceptor::accept`] call: the open's echo fields
/// (the listen endpoint the arrival belongs to — the assembly's
/// routing key; the OpenSSH matcher parity verbatim) + the originator
/// (the arrival's peer, as the wire carried it) + the channel duplex.
pub struct ForwardedTcpip {
/// The connected address the open echoed — the `reverse_listen`
/// wire string (sshd parity).
pub connected_address: String,
/// The actually-bound port of the minted listener — for a
/// `port = 0` mint this is the server-picked number, never `0`.
pub connected_port: u32,
/// The arrival's peer address (the wire originator).
pub originator_address: String,
/// The arrival's peer port (the wire originator).
pub originator_port: u32,
/// The accepted channel's byte duplex — the acceptor owns the
/// pump from the handoff on (dropping it is wire-clean).
pub stream: TunnelChannel,
}
impl std::fmt::Debug for ForwardedTcpip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForwardedTcpip")
.field("connected_address", &self.connected_address)
.field("connected_port", &self.connected_port)
.field("originator_address", &self.originator_address)
.field("originator_port", &self.originator_port)
.finish_non_exhaustive()
}
}
/// An accepted server-initiated `forwarded-streamlocal` open — the
/// streamlocal acceptor's payload (the echoed socket path + the
/// channel duplex).
pub struct ForwardedStreamlocal {
/// The socket path the open echoed — the `reverse_listen_unix`
/// wire string (sshd parity).
pub socket_path: String,
/// The accepted channel's byte duplex — the acceptor owns the
/// pump from the handoff on (dropping it is wire-clean).
pub stream: TunnelChannel,
}
impl std::fmt::Debug for ForwardedStreamlocal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForwardedStreamlocal")
.field("socket_path", &self.socket_path)
.finish_non_exhaustive()
}
}
/// The `forwarded-tcpip` acceptor — the registry's per-type slot
/// trait. `accept` runs on the spawned handoff task (never the
/// packet loop): the assembly's pump/home of the open lives inside
/// it; the crate releases its handoff counter when the call returns,
/// so the family shape (hand off to a queue, return) keeps the
/// counter's meaning "open accepted, handoff in flight".
#[async_trait]
pub trait TcpipForwardedAcceptor: Send + Sync {
/// Serves one accepted open. Never invoked for an unregistered
/// type (the registry gate is first); the open is already accepted
/// on the wire — refusing one from here is a *data-plane* choice
/// (drop the duplex; the channel closes on it), never a wire
/// open-decline.
async fn accept(&self, open: ForwardedTcpip);
}
/// The `forwarded-streamlocal` acceptor — the unix slot's trait
/// (see [`TcpipForwardedAcceptor`] for the call-shape contract).
#[async_trait]
pub trait StreamlocalForwardedAcceptor: Send + Sync {
/// Serves one accepted open (platform-neutral pumping — see the
/// module docs' unix-gate record).
async fn accept(&self, open: ForwardedStreamlocal);
}
/// The held-slot refusal (`AcceptorRegistry::install_*`) — the
/// refuse-never-replace posture (the server-side `ListenRouter`'s
/// recorded sibling): the slot already holds a live acceptor; a second
/// install is the assembly's program error, declined, never a quiet
/// displacement. An explicit `uninstall_*` frees the slot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("an acceptor is already registered for this channel type")]
pub struct AcceptorHeld;
/// The assembly-mutable, per-channel-type acceptor registry — the
/// `-R` data leg's fail-closed gate (see the module docs for the
/// recorded decisions). Constructed empty at the dial
/// ([`SshConnection::acceptors`], the live-connection accessor);
/// install/uninstall is the assembly's to drive whenever it decides.
pub struct AcceptorRegistry {
tcpip: Mutex<Option<Arc<dyn TcpipForwardedAcceptor>>>,
streamlocal: Mutex<Option<Arc<dyn StreamlocalForwardedAcceptor>>>,
}
impl AcceptorRegistry {
/// The empty registry — the dial's construct (nothing is served
/// until an acceptor installs; ADR-003 §4's fail-closed default).
pub fn new() -> Self {
Self {
tcpip: Mutex::new(None),
streamlocal: Mutex::new(None),
}
}
/// Installs the `forwarded-tcpip` acceptor. Refuse-never-replace:
/// a held slot declines ([`AcceptorHeld`]) — uninstall first if a
/// replacement is meant (the late-install toggle's other half).
pub fn install_tcpip(
&self,
acceptor: Arc<dyn TcpipForwardedAcceptor>,
) -> Result<(), AcceptorHeld> {
let mut slot = self.tcpip.lock();
if slot.is_some() {
return Err(AcceptorHeld);
}
*slot = Some(acceptor);
Ok(())
}
/// Uninstalls the `forwarded-tcpip` acceptor — `true` when one was
/// held (the toggle's honest report). In-flight opens already
/// accepted keep pumping (their handoff happened); arrivals after
/// this decline at arrival time (fail-closed, no buffering).
pub fn uninstall_tcpip(&self) -> bool {
self.tcpip.lock().take().is_some()
}
/// Installs the `forwarded-streamlocal` acceptor (see
/// [`Self::install_tcpip`] for the refuse-never-replace posture).
pub fn install_streamlocal(
&self,
acceptor: Arc<dyn StreamlocalForwardedAcceptor>,
) -> Result<(), AcceptorHeld> {
let mut slot = self.streamlocal.lock();
if slot.is_some() {
return Err(AcceptorHeld);
}
*slot = Some(acceptor);
Ok(())
}
/// Uninstalls the `forwarded-streamlocal` acceptor (see
/// [`Self::uninstall_tcpip`] for the honest-report posture).
pub fn uninstall_streamlocal(&self) -> bool {
self.streamlocal.lock().take().is_some()
}
/// The arrival-time slot reads (the open hooks' gates) — cloned
/// out so the spawned handoff task owns its acceptor handle.
pub(crate) fn tcpip_acceptor(&self) -> Option<Arc<dyn TcpipForwardedAcceptor>> {
self.tcpip.lock().clone()
}
pub(crate) fn streamlocal_acceptor(&self) -> Option<Arc<dyn StreamlocalForwardedAcceptor>> {
self.streamlocal.lock().clone()
}
}
impl Default for AcceptorRegistry {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for AcceptorRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcceptorRegistry")
.field("tcpip", &self.tcpip.lock().is_some())
.field("streamlocal", &self.streamlocal.lock().is_some())
.finish()
}
}
// ---------------------------------------------------------------------------
// The server-initiated opens' handoff (the dial handler's routed paths)
// ---------------------------------------------------------------------------
/// The accepted `forwarded-tcpip` open's handoff task — the
/// spawn-and-return rule's client mirror (ADR-006 decision 4; the
/// hook runs in the packet loop and returns fast): accepts the open on
/// the wire, converts the channel to the family duplex, delivers the
/// payload to the assembly's acceptor, and releases the handoff
/// counter when the acceptor's call completes (the recorded release
/// site — the count's meaning is "accepted, handoff in flight").
#[cfg_attr(feature = "tunnel", allow(clippy::too_many_arguments))]
pub(crate) async fn handoff_forwarded_tcpip(
acceptor: Arc<dyn TcpipForwardedAcceptor>,
open_cap: Arc<ChannelCap>,
channel: russh::Channel<russh::client::Msg>,
reply: ChannelOpenHandle,
connected_address: String,
connected_port: u32,
originator_address: String,
originator_port: u32,
) {
let channel_number = channel.id().number();
observe::serving_path_started("forwarded-tcpip", channel_number, None);
reply.accept().await;
let open = ForwardedTcpip {
connected_address,
connected_port,
originator_address,
originator_port,
stream: TunnelChannel {
stream: channel.into_stream(),
},
};
acceptor.accept(open).await;
open_cap.release();
observe::serving_path_ended("forwarded-tcpip", channel_number, None);
}
/// The accepted `forwarded-streamlocal` open's handoff task — the
/// unix slot's mirror of [`handoff_forwarded_tcpip`].
pub(crate) async fn handoff_forwarded_streamlocal(
acceptor: Arc<dyn StreamlocalForwardedAcceptor>,
open_cap: Arc<ChannelCap>,
channel: russh::Channel<russh::client::Msg>,
reply: ChannelOpenHandle,
socket_path: String,
) {
let channel_number = channel.id().number();
observe::serving_path_started("forwarded-streamlocal", channel_number, None);
reply.accept().await;
let open = ForwardedStreamlocal {
socket_path,
stream: TunnelChannel {
stream: channel.into_stream(),
},
};
acceptor.accept(open).await;
open_cap.release();
observe::serving_path_ended("forwarded-streamlocal", channel_number, None);
}
// ---------------------------------------------------------------------------
// The reverse-listen rows (under `tunnel`)
// ---------------------------------------------------------------------------
impl SshConnection {
/// The reverse-listen row (`-R`): asks the vanilla server to listen
/// on (address, port) and returns the **actually-bound port** —
/// for `port = 0` this is the peer-picked number (`client.md`
/// §Lifecycle: the bound-port primitive, the port-0 mint); for an
/// explicit port the wire's success reply carries no number (russh/
/// sshd parity — the reply's data only exists for mints) and the
/// bound port *is* the requested one, which the row surfaces.
///
/// Gates, both **before any wire op**: the `forwarded-tcpip`
/// acceptor must be registered
/// ([`OpError::NoAcceptor`](crate::OpError) — a listen whose data
/// leg cannot serve is a half-capability) and the connection's
/// listener count must have room
/// ([`OpError::ListenCapExceeded`](crate::OpError) — the
/// dedicated counter sharing
/// [`Limits::max_concurrent_channels`](crate::Limits); see the
/// module docs' cap relation). The peer's refusal is
/// [`OpError::ListenRefused`](crate::OpError) (the wire's
/// `RequestDenied`) and releases the count; the connection stays
/// usable after every refusal class. The listener is explicitly
/// cancellable ([`SshConnection::reverse_listen_cancel`]; it is
/// not implicitly unlistened by anything but the connection's
/// end).
pub async fn reverse_listen(
&self,
address: impl Into<String>,
port: u32,
) -> Result<u32, OpError> {
if self.acceptors().tcpip_acceptor().is_none() {
return Err(OpError::NoAcceptor);
}
if !self
.listener_budget()
.admit(self.limits().max_concurrent_channels)
{
return Err(OpError::ListenCapExceeded);
}
let bound = match self.russh_handle().tcpip_forward(address, port).await {
Ok(minted) if port == 0 => minted,
Ok(_) => port,
Err(error) => {
self.listener_budget().release();
return Err(OpError::from(error));
}
};
Ok(bound)
}
/// The reverse-listen cancel row (`-R` teardown): the explicit
/// cancellation of one bound listener (the lifecycle primitive:
/// every listener explicitly cancellable, the wire-honest path —
/// the peer tears its listen down, the bound port frees). Matches
/// by the raw wire strings (sshd parity). The peer's refusal
/// (unknown listener) is [`OpError::ListenRefused`](crate::OpError);
/// a confirmed cancel releases the listener count.
pub async fn reverse_listen_cancel(
&self,
address: impl Into<String>,
port: u32,
) -> Result<(), OpError> {
match self
.russh_handle()
.cancel_tcpip_forward(address, port)
.await
{
Ok(()) => {
self.listener_budget().release();
Ok(())
}
Err(error) => Err(OpError::from(error)),
}
}
/// The streamlocal reverse-listen row (the unix `-R`): asks the
/// vanilla server to listen on the unix socket at `socket_path`,
/// minting no port (the global request's success is the outcome;
/// the accepted opens' echo carries the path). Same gates as
/// [`SshConnection::reverse_listen`] for the
/// `forwarded-streamlocal` slot, plus the **runtime-Unix gate**
/// (OQ-11: off-Unix, [`OpError::UnixUnavailable`](crate::OpError)
/// before any wire op — the bind is the platform-bound operation;
/// the channel ingestion is platform-neutral).
#[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix(&self, socket_path: impl Into<String>) -> Result<(), OpError> {
#[cfg(not(unix))]
{
return Err(OpError::UnixUnavailable);
}
#[cfg(unix)]
{
if self.acceptors().streamlocal_acceptor().is_none() {
return Err(OpError::NoAcceptor);
}
if !self
.listener_budget()
.admit(self.limits().max_concurrent_channels)
{
return Err(OpError::ListenCapExceeded);
}
match self.russh_handle().streamlocal_forward(socket_path).await {
Ok(()) => Ok(()),
Err(error) => {
self.listener_budget().release();
Err(OpError::from(error))
}
}
}
}
/// The streamlocal cancel row (matches by the socket path, sshd
/// parity — see [`SshConnection::reverse_listen_cancel`]).
#[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix_cancel(
&self,
socket_path: impl Into<String>,
) -> Result<(), OpError> {
#[cfg(not(unix))]
{
return Err(OpError::UnixUnavailable);
}
#[cfg(unix)]
{
match self
.russh_handle()
.cancel_streamlocal_forward(socket_path)
.await
{
Ok(()) => {
self.listener_budget().release();
Ok(())
}
Err(error) => Err(OpError::from(error)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::AcceptorHeld;
use crate::client_accept::{
AcceptorRegistry, ForwardedStreamlocal, ForwardedTcpip, StreamlocalForwardedAcceptor,
TcpipForwardedAcceptor,
};
use std::sync::Arc;
struct StubTcpip;
#[async_trait::async_trait]
impl TcpipForwardedAcceptor for StubTcpip {
async fn accept(&self, _open: ForwardedTcpip) {}
}
struct StubStreamlocal;
#[async_trait::async_trait]
impl StreamlocalForwardedAcceptor for StubStreamlocal {
async fn accept(&self, _open: ForwardedStreamlocal) {}
}
/// - *Contract stamp:* the recorded refuse-never-replace posture —
/// a held slot declines the second install (never a quiet
/// displacement of a live acceptor); uninstall frees it.
#[test]
fn install_refuses_a_held_slot_and_uninstall_frees_it() {
let registry = AcceptorRegistry::new();
let first: Arc<dyn TcpipForwardedAcceptor> = Arc::new(StubTcpip);
assert!(registry.install_tcpip(Arc::clone(&first)).is_ok());
assert!(
registry.install_tcpip(Arc::new(StubTcpip)).is_err(),
"a held slot is refused, never replaced"
);
let held = registry.tcpip_acceptor().expect("the first stays held");
assert!(Arc::ptr_eq(&held, &first));
assert!(registry.uninstall_tcpip());
assert!(
!registry.uninstall_tcpip(),
"the second uninstall is the honest `false`"
);
assert!(registry.tcpip_acceptor().is_none());
assert!(
registry.install_tcpip(first).is_ok(),
"an uninstalled slot installs again (the late-install toggle)"
);
}
/// - *Contract stamp:* the two slots are independent (the
/// per-type-registry decision) — the empty streamlocal slot
/// stays unfeatured while the tcpip slot serves.
#[test]
fn the_slots_are_independent() {
let registry = AcceptorRegistry::new();
assert!(registry.tcpip_acceptor().is_none());
assert!(registry.streamlocal_acceptor().is_none());
registry
.install_tcpip(Arc::new(StubTcpip))
.expect("the tcp slot installs");
assert!(registry.tcpip_acceptor().is_some());
assert!(
registry.streamlocal_acceptor().is_none(),
"the streamlocal slot is untouched"
);
registry
.install_streamlocal(Arc::new(StubStreamlocal))
.expect("the streamlocal slot installs independently");
assert!(registry.streamlocal_acceptor().is_some());
}
/// - *Contract stamp:* the Debug shape (the installed-slot fact,
/// never internals).
#[test]
fn the_registry_debug_shapes_the_slots() {
let registry = AcceptorRegistry::new();
let debug = format!("{registry:?}");
assert!(
debug.contains("tcpip: false") && debug.contains("streamlocal: false"),
"{debug}"
);
registry
.install_tcpip(Arc::new(StubTcpip))
.expect("installs");
let debug = format!("{registry:?}");
assert!(debug.contains("tcpip: true"), "{debug}");
}
/// - *Contract stamp:* the held-slot error's honest shape (no
/// `Other`, no catch-all).
#[test]
fn acceptor_held_error_shapes() {
let held = AcceptorHeld;
assert_eq!(
held.to_string(),
"an acceptor is already registered for this channel type"
);
}
}
+189 -33
View File
@@ -20,25 +20,39 @@
//! 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
//! explicit-over-inherited. The `forwarded-tcpip` /
//! `forwarded-streamlocal` hooks (the `-R` data leg's types) route
//! through the **acceptor registry** ([`crate::client_accept`] —
//! the `w4-reverse-listen` surface): served only under a registered
//! acceptor for that type and under the `tunnel` feature (the
//! [`with_tunnel_surface`](ClientHandler::with_tunnel_surface)
//! wiring; a build without `tunnel` has no registry — every hook
//! declines); unregistered/unfeatured opens answer
//! [`ChannelOpenFailure::AdministrativelyProhibited`] — a clean
//! wire-visible refusal the peer observes as an
//! unconfirmed open, never an accepted-then-dropped channel. The
//! unknown-type gate
//! accepted open's handoff spawns (the spawn-and-return rule's
//! client mirror): the hook accepts and returns fast, the duplex +
//! the open's fields ride the payload to the assembly's acceptor
//! call on its own task. Every other type has no registry slot —
//! the unknown-type gate
//! ([`Handler::should_accept_unknown_server_channel`]) answers
//! `false` (the hook's reject-before-open form; its `_unknown`
//! sibling stays a defensive reject — wire-reachable only if that
//! gate ever changed, also fail-closed). 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.
//! gate ever changed, also fail-closed), and the never-feature rows
//! (x11, agent) decline at any configuration.
//!
//! 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`).
//! The accepted-open handoff carries a **bounded bookkeeping**
//! counter (`AGENTS.md` convention 9, the cap relation recorded in
//! [`crate::client_accept`]): a dedicated per-connection counter —
//! sharing the dial's `Limits::max_concurrent_channels` ceiling the
//! server direction's per-session cap uses — admitted in the hook
//! before the accept, released when the acceptor's call completes in
//! the spawned task; a full counter declines the arrival cleanly
//! (`cap-exceeded`).
#[cfg(feature = "tunnel")]
use std::sync::Arc;
use russh::ChannelOpenFailure;
use russh::client::ChannelOpenHandle;
@@ -46,10 +60,30 @@ use russh::client::Msg;
use russh::keys::PublicKeyOrCertificate;
use russh::{Channel, ChannelId};
#[cfg(feature = "tunnel")]
use crate::channel_cap::ChannelCap;
#[cfg(feature = "tunnel")]
use crate::client_accept::{
AcceptorRegistry, handoff_forwarded_streamlocal, handoff_forwarded_tcpip,
};
#[cfg(feature = "tunnel")]
use crate::config::Limits;
use crate::host_key::{HostKeyPolicy, PresentedHostKey};
use crate::observe;
use crate::{DialError, fingerprint};
/// The `-R` leg's per-connection client-handler state (the
/// `w4-reverse-listen` surface, under `tunnel`): the acceptor registry
/// the two forwarded hooks route through, plus the accepted-open
/// handoff counter and its ceiling (the dial's
/// `Limits::max_concurrent_channels`).
#[cfg(feature = "tunnel")]
pub(crate) struct TunnelSurface {
acceptors: Arc<AcceptorRegistry>,
limits: Limits,
open_cap: std::sync::Arc<ChannelCap>,
}
/// The per-connection client handler the dial surface constructs —
/// see the module docs. Test surface
/// (`#[doc(hidden)]` re-export from the crate root): the contract
@@ -59,6 +93,11 @@ pub struct ClientHandler {
policy: std::sync::Arc<dyn HostKeyPolicy>,
host: String,
port: u16,
/// The `-R` surface under `tunnel` (`Some` — the dial wires it;
/// the bare-hook tests construct without it to pin the decline-all
/// default posture in the featured builds too).
#[cfg(feature = "tunnel")]
tunnel: Option<TunnelSurface>,
}
impl ClientHandler {
@@ -74,6 +113,24 @@ impl ClientHandler {
policy,
host: host.into(),
port,
#[cfg(feature = "tunnel")]
tunnel: None,
}
}
/// Wires the `-R` surface (the dial's step under `tunnel`): the
/// acceptor registry (constructed empty at the dial; the assembly
/// installs through the connection's accessor) + the
/// accepted-open counter against the limits' channel ceiling.
#[cfg(feature = "tunnel")]
pub fn with_tunnel_surface(self, acceptors: Arc<AcceptorRegistry>, limits: Limits) -> Self {
Self {
tunnel: Some(TunnelSurface {
acceptors,
limits,
open_cap: Arc::new(ChannelCap::default()),
}),
..self
}
}
}
@@ -125,39 +182,138 @@ impl russh::client::Handler for ClientHandler {
Ok(())
}
/// The `forwarded-tcpip` hook (the `-R` data leg's gate): the
/// registry's slot read at arrival time — registered + room →
/// accept and hand the assembly the open on a spawned task; no
/// acceptor / no room → the clean decline, never accept-and-drop.
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: Channel<Msg>,
_connected_address: &str,
_connected_port: u32,
_originator_address: &str,
_originator_port: u32,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] connected_address: &str,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] connected_port: u32,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] originator_address: &str,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
#[cfg(not(feature = "tunnel"))]
{
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
#[cfg(feature = "tunnel")]
{
let Some(surface) = self.tunnel.as_ref() else {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
let Some(acceptor) = surface.acceptors.tcpip_acceptor() else {
observe::decline_open("forwarded-tcpip", "no-acceptor", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
if !surface
.open_cap
.admit(surface.limits.max_concurrent_channels)
{
observe::decline_open("forwarded-tcpip", "cap-exceeded", channel.id().number());
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
tokio::spawn(handoff_forwarded_tcpip(
acceptor,
Arc::clone(&surface.open_cap),
channel,
reply,
connected_address.to_owned(),
connected_port,
originator_address.to_owned(),
originator_port,
));
Ok(())
}
}
/// The `forwarded-streamlocal` hook — the unix-slot's mirror of
/// the tcpip gate (the ingestion is platform-neutral: no runtime
/// gate here; the listen rows carry the OQ-11 gate on the bind —
/// the recorded asymmetry, see [`crate::client_accept`]).
async fn server_channel_open_forwarded_streamlocal(
&mut self,
channel: Channel<Msg>,
_socket_path: &str,
#[cfg_attr(not(feature = "tunnel"), allow(unused_variables))] 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(())
#[cfg(not(feature = "tunnel"))]
{
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
#[cfg(feature = "tunnel")]
{
let Some(surface) = self.tunnel.as_ref() else {
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
let Some(acceptor) = surface.acceptors.streamlocal_acceptor() else {
observe::decline_open(
"forwarded-streamlocal",
"no-acceptor",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
};
if !surface
.open_cap
.admit(surface.limits.max_concurrent_channels)
{
observe::decline_open(
"forwarded-streamlocal",
"cap-exceeded",
channel.id().number(),
);
reply
.reject(ChannelOpenFailure::AdministrativelyProhibited)
.await;
return Ok(());
}
tokio::spawn(handoff_forwarded_streamlocal(
acceptor,
Arc::clone(&surface.open_cap),
channel,
reply,
socket_path.to_owned(),
));
Ok(())
}
}
async fn server_channel_open_direct_tcpip(
@@ -247,10 +403,10 @@ impl russh::client::Handler for ClientHandler {
/// 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 std::sync::Arc;
use super::ClientHandler;
use crate::host_key::HostKeyPolicy;
+9 -2
View File
@@ -541,7 +541,11 @@ async fn write_exit_chunk(
/// surface — the duplex's `poll_shutdown` carries the wire EOF.
#[cfg(feature = "tunnel")]
pub struct TunnelChannel {
stream: russh::ChannelStream<russh::client::Msg>,
/// The russh channel's byte duplex (`pub(crate)` — the `-R` leg's
/// acceptor payloads construct the type over a server-initiated
/// open's `into_stream()` conversion; downstream crates see the
/// type opaque as ever).
pub(crate) stream: russh::ChannelStream<russh::client::Msg>,
}
#[cfg(feature = "tunnel")]
@@ -836,7 +840,9 @@ async fn git_output_pump(
#[cfg(all(test, any(feature = "session", feature = "git", feature = "tunnel")))]
mod tests {
use super::{family_exit_code, open_error};
#[cfg(any(feature = "session", feature = "git"))]
use super::family_exit_code;
use super::open_error;
use crate::error::OpError;
use russh::ChannelOpenFailure;
@@ -865,6 +871,7 @@ mod tests {
/// - *Contract stamp:* the family exit-currency clamp (§the
/// family plane) — ordinary codes ride identity, beyond-range
/// ones narrow (never invent).
#[cfg(any(feature = "session", feature = "git"))]
#[test]
fn family_exit_code_narrows_beyond_the_i32_range() {
assert_eq!(family_exit_code(0), 0);
+53 -3
View File
@@ -338,6 +338,36 @@ pub enum OpError {
#[error("unix socket forwarding is unavailable on this platform")]
UnixUnavailable,
/// The reverse-forward op's channel type has **no acceptor
/// registered** (ADR-003 §4's client-side gate surfaced to the
/// *issuing* side: a reverse listen whose data leg could not serve
/// arrivals is issued never — the half-capability posture,
/// answered before any wire op). Featured-but-unregistered is the
/// exact class: the unset-declines convention mirrored (the
/// acceptor slots are assembly-mutable, install-first is the
/// recorded order).
#[error("no acceptor is registered for the op's forwarded channel type")]
NoAcceptor,
/// The peer refused the op's reverse-forward global request (the
/// `RequestDenied` wire decline class) — a listen denial or the
/// cancel forms' unknown-listener answer (sshd parity: match by
/// the raw wire strings). Channel-scoped honesty: the connection
/// stays usable (row-pinned), and the wire's own reason classes
/// are never invented here — the closed family class mirrors the
/// wire shape, `RequestRefused`'s global-request counterpart.
#[error("the peer refused the reverse-forward request")]
ListenRefused,
/// The op's reverse-listen admission hit the connection's
/// dedicated listener ceiling (the listener counter sharing
/// `Limits::max_concurrent_channels` — the server-side
/// listener-counter posture mirrored; the cap relation recorded in
/// `client_accept.rs`) — answered **before any wire op**: a
/// request the budget cannot track is issued never.
#[error("the connection's reverse-listen count is at its ceiling")]
ListenCapExceeded,
/// The sftp route is the OQ-13 decline placeholder (the feature
/// exists, the route does not until alkfs lands, on either
/// direction) — answered before any wire op, never a
@@ -352,14 +382,18 @@ pub enum OpError {
}
impl From<russh::Error> for OpError {
/// The one mirror point: a typed open's rejection is the wire
/// shape `russh::Error::ChannelOpenFailure` and maps to
/// The mirror points: a typed open's rejection is the wire shape
/// `russh::Error::ChannelOpenFailure` and maps to
/// [`OpError::OpenDeclined`] (family-mirrored
/// [`OpenDeclineReason`]); every other russh error stays the
/// [`OpenDeclineReason`]); a denied reverse-forward *global*
/// request (`RequestDenied` — the listen/cancel forms' only wire
/// decline shape at this pin, the rows' only producer) maps to
/// [`OpError::ListenRefused`]; every other russh error stays the
/// transparent [`OpError::Wire`].
fn from(error: russh::Error) -> Self {
match error {
russh::Error::ChannelOpenFailure(failure) => Self::OpenDeclined(failure.into()),
russh::Error::RequestDenied => Self::ListenRefused,
other => Self::Wire(other),
}
}
@@ -586,6 +620,9 @@ mod tests {
OpError::CommandTooLong,
OpError::InvalidRepoId,
OpError::UnixUnavailable,
OpError::NoAcceptor,
OpError::ListenRefused,
OpError::ListenCapExceeded,
OpError::SftpNotServed,
OpError::Wire(russh::Error::Disconnect),
]);
@@ -601,6 +638,9 @@ mod tests {
OpError::CommandTooLong => "command-too-long",
OpError::InvalidRepoId => "invalid-repo-id",
OpError::UnixUnavailable => "unix-unavailable",
OpError::NoAcceptor => "no-acceptor",
OpError::ListenRefused => "listen-refused",
OpError::ListenCapExceeded => "listen-cap-exceeded",
OpError::SftpNotServed => "sftp-not-served",
OpError::Wire(_) => "wire",
};
@@ -609,6 +649,16 @@ mod tests {
}
}
/// - *Contract stamp:* the reverse-forward global request's wire
/// decline class (`RequestDenied` — the listen/cancel forms'
/// only producer shape at this pin): `ListenRefused`, never the
/// transparent wire class; the typed-open mapping unchanged.
#[test]
fn op_error_request_denied_maps_to_listen_refused() {
let mapped: OpError = russh::Error::RequestDenied.into();
assert!(matches!(mapped, OpError::ListenRefused), "got {mapped:?}");
}
/// - *Contract stamp:* POC-1 §6 — a rejected channel open
/// surfaces on the awaiting typed call as
/// `russh::Error::ChannelOpenFailure(..)`; the op rows map it
+15 -4
View File
@@ -20,14 +20,16 @@
//!
//! Architecture: `docs/architecture/` (phase 1 — ADRs 001–006 accepted;
//! the foundations, the server stack, the server-direction mappings, and
//! the client dial + op rows are landed through wave 4; the `-R` leg and
//! lifecycle primitives, the validation suite, and the release are later
//! implementation waves). See also `docs/research/`,
//! the client dial + op rows + the `-R` acceptor leg are landed through
//! wave 4; the lifecycle primitives, 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;
#[cfg(feature = "tunnel")]
mod client_accept;
mod client_handler;
mod client_ops;
mod config;
@@ -49,13 +51,22 @@ mod tty_map;
mod tunnel_map;
pub use adapter::SshAdapter;
pub use client::SshConnection;
pub use client::dial;
/// 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};
/// The `-R` leg's acceptor registry + the reverse-listen rows'
/// payload/acceptor types (`w4-reverse-listen` — under `tunnel`; the
/// recorded decisions live in the `client_accept` module docs).
#[cfg(feature = "tunnel")]
pub use client_accept::{
AcceptorHeld, AcceptorRegistry, ForwardedStreamlocal, ForwardedTcpip,
StreamlocalForwardedAcceptor, TcpipForwardedAcceptor,
};
#[doc(hidden)]
pub use client_handler::ClientHandler;
/// The client op rows' surfaces (`w4-op-rows`):
+115 -8
View File
@@ -1,7 +1,7 @@
---
id: w4-reverse-listen
name: Wave 4 — the client -R leg (forward requests + the acceptor registry)
status: pending
status: completed
depends_on: [w4-op-rows]
scope: moderate
risk: high
@@ -65,21 +65,21 @@ registry that serves:
## Acceptance Criteria
- [ ] `tcpip_forward` / `streamlocal_forward` (+ cancel forms) land
- [x] `tcpip_forward` / `streamlocal_forward` (+ cancel forms) land
with the actually-bound port surfaced (the port-0 mint row);
the runtime-Unix gate compiles everywhere, declines cleanly
off-Unix
- [ ] The acceptor registry lands (shape decided + recorded): served
- [x] The acceptor registry lands (shape decided + recorded): served
only under a registered acceptor under `tunnel`;
unregistered/unfeatured server-initiated opens declined cleanly
(never accept-and-drop) — wire rows per build
- [ ] Late-install works across a live connection; a pre-install
- [x] Late-install works across a live connection; a pre-install
arrival is declined (decided + recorded, no unclaimed buffering)
- [ ] The data leg hands accepted opens to the assembly handler;
- [x] The data leg hands accepted opens to the assembly handler;
bookkeeping bounded (the cap relation decided + recorded)
- [ ] Per-build contract rows green through the server double; the
- [x] Per-build contract rows green through the server double; the
smoke posture decided + recorded
- [ ] `cargo test` (+ per-feature + `--all-features`), clippy ×2
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
(`-D warnings`), fmt, `cargo doc --no-deps` pass
## References
@@ -104,6 +104,113 @@ registry that serves:
> To be filled by implementation agent
Implementation decisions (also recorded in `src/client_accept.rs`'s
module docs and `client.md` §The acceptor's landing block):
- **Registry shape — per-type fixed slots, not a typed map**: the
client-direction registry-servable set is closed (exactly the two
`-R` data-leg types under `tunnel`); one acceptor trait per type
(`TcpipForwardedAcceptor` / `StreamlocalForwardedAcceptor`), the
payload an accepted open hands the assembly: the channel duplex
(family-typed — the `-L` rows' `TunnelChannel`, one type both legs
ride) + the open's echo/originator fields — the harness
`ForwardedAcceptor` vocabulary grown, not forked. Install is
**refuse-never-replace** (`AcceptorHeld` — the server-side
`ListenRouter::register` posture mirrored); uninstall is explicit
and reports honestly. A new module `src/client_accept.rs` (one
module per file; the rows' method bodies live beside the registry).
- **The listen rows gate on the registered acceptor** (the
issuing-side disposition — a listen whose data leg cannot serve is
never asked for; install-first recorded order;
`OpError::NoAcceptor` pre-wire). The cancel forms do *not* gate on
the acceptor (teardown must work after an uninstall).
- **Bound-port mapping** (recorded wire fact at this pin): russh's
client `tcpip_forward` reply carries a port **only for
`port = 0` requests** (sshd parity — an explicit request's bound
port *is* the requested port); `reverse_listen` maps both to the
actually-bound number it returns.
- **Bounded bookkeeping — the cap relation**: two dedicated
`ChannelCap` counters, each against the same
`Limits::max_concurrent_channels` ceiling (the server-side
listener/ListenerCap posture mirrored): (a) the per-connection
**reverse-listen count** — admitted at the listen rows, released at
confirmed cancels and the refused/wire-error paths (never a map —
the outstanding listens live at the peer); (b) the **accepted-open
handoff count** — admitted in the dial handler's open hook before
the accept, released when the acceptor's `accept` call completes in
the spawned handoff task; a full counter declines the arrival
(`cap-exceeded`, event vocabulary). No accepted-open ledger: the
pump is the acceptor's from the handoff on (the family handler
shape).
- **The runtime-Unix gate rides the unix *listen* rows only** (the
bind is the platform-bound op; `forwarded-streamlocal` *ingestion*
is platform-neutral byte pumping, hook universal compile).
- **Pre-existing wart fixed**: `client_ops.rs`'s test mod gated
`family_exit_code` under a `tunnel`-inclusive cfg — the
`--features tunnel` build did not compile its tests before this
task; the import/test are now gated to the exit-consuming builds.
Surprises: russh's client reply shapes (above, the port-0-only mint)
and the `JoinHandle::drop`-is-detach-not-cancel behavior in the
double's cancel teardown (first draft removed the entry without
aborting the accept loop — the real listener stayed bound; the double
aborts explicitly now).
## Summary
> To be filled on completion
> To be filled on completion
Landed (`feat(client)`: w4-reverse-listen):
- **`src/client_accept.rs`** — the acceptor registry
(`AcceptorRegistry` + the two per-type acceptor traits + the
`ForwardedTcpip`/`ForwardedStreamlocal` payloads + `AcceptorHeld`),
the reverse-listen rows (`reverse_listen` / `reverse_listen_cancel`
/ the unix pair, with the gates + dedicated counters), and the
accepted-opens' handoff tasks; exports from the crate root under
`tunnel`.
- **`src/client.rs`** — the dial wires the (empty) registry +
tunnel surface into the handler and the connection
(`SshConnection::acceptors` — the live-connection accessor;
the listener budget's private field).
- **`src/client_handler.rs`** — the two forwarded hooks route through
the registry at arrival time (registered + room → accept + spawned
handoff; unregistered/cap-exhausted → clean `Administratively-
Prohibited` decline with the event vocabulary); every other type
keeps the decline-all skeleton; the never-feature rows unchanged.
- **`src/error.rs`** — `OpError::NoAcceptor` / `ListenRefused` /
`ListenCapExceeded` + the `RequestDenied → ListenRefused` mapping
(the listen/cancel forms' only wire decline shape at this pin).
- **`tests/common/sshd_double.rs`** — the fake `sshd`'s `-R` server
half: the four listen/cancel hooks (scripted accept/deny via
`DblListen`; an accept binds a **real** socket, mints the real
bound port for `port = 0` into the out-param, spawns the accept
loop — per accepted connection a server-initiated `forwarded-*`
open echoing the listen's args + the peer, then the bytes pumped;
cancels match by the raw wire strings, tearing the real listener
down) + the listen/cancel observations.
- **`tests/client_reverse_listen.rs`** (`tunnel` build) — the
contract rows: listen + data-leg pump round trip (real
`ssh -R`-shaped), the port-0 mint, the no-acceptor pre-wire
declines (listen + open, with the event rows), the late-install
rows (pre-install declined → serve-after; uninstall takes it off),
the cancel rows (teardown observable — reconnect refused; unknown
→ `ListenRefused`), the listener-ceiling row (exceeded pre-wire;
cancel recovers), the registry contract through the accessor, and
the streamlocal unix rows (+ the compiled off-Unix gate row).
Per-build matrix: the *unfeatured* builds' wire rows run in every
build through `tests/client_dial.rs`'s decline-all row (the
surface itself is the compile-time mirror).
- **`tests/tunnel_mapping.rs`** — the `client_smoke` module: the
full-circle alkssh-client ↔ alkssh-server-direction smoke (both
`-R` legs through the alkcall fabric double over a real TCP
mount) — the port-0 mint through the server direction's fabric
listen + arrival pump into the registered acceptor's duplex, bytes
round-tripped end to end; real-`sshd` `-R` remains wave-5 e2e
(recorded, never silently dropped).
Verification: `cargo test` (default) / `--features <each>` /
`--all-features` all green; `cargo clippy --all-targets -- -D
warnings` × builds (none / all-features) clean; `cargo fmt --check`
clean; `cargo doc --no-deps` (+ `--all-features`; only the
pre-existing `client_ops.rs` private-link warnings remain).
+795
View File
@@ -0,0 +1,795 @@
//! The `-R` leg's contract rows (`w4-reverse-listen`) — driven through
//! the in-process fake `sshd` grown with the reverse-forward surfaces
//! ([`sshd_double`](common::sshd_double): the `tcpip-forward` /
//! `streamlocal-forward` hooks that bind **real** sockets and open the
//! `forwarded-*` channels toward the client per accepted connection):
//! the listen rows (featured + registered → served, the port-0 mint,
//! the cancels + the listener-cap ceiling), the acceptor-registry rows
//! (featured + unregistered → clean decline; late-install across a
//! live connection — decline-before-install, serve-after; uninstall;
//! the data-leg pump round trip), the off-Unix streamlocal rows
//! (OQ-11), and the connection-stays-usable row after every decline
//! class. The row/stamp discipline (`tests/common/mod.rs`'s doc)
//! applies verbatim.
//!
//! **Per-build matrix note (recorded):** this file is the
//! `tunnel`-featured build's rows (the only build whose surface can
//! issue the reverse rows at all — the unfeatured build's compile-time
//! mirror); the wire decline rows for the *unfeatured* builds run in
//! every build through `tests/client_dial.rs`'s
//! `server_initiated_opens_decline_cleanly_on_the_wire` (the
//! decline-all skeleton the dial carries in a build without `tunnel`
//! — no registry exists there, every hook declines).
#![cfg(feature = "tunnel")]
mod common;
use std::sync::Arc;
use std::time::Duration;
use alkssh::{
AcceptorHeld, AcceptorRegistry, DialConfig, ForwardedStreamlocal, ForwardedTcpip, OpError,
SshConnection, StreamlocalForwardedAcceptor, TcpipForwardedAcceptor, dial,
};
use async_trait::async_trait;
use common::sshd_double::{DblListen, SshdDouble, SshdObserved, SshdScript, spawn_scripted};
use common::{MemHostKeyPolicy, TestVault, fixture};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A vault-backed key source over the alpha fixture — the dial
/// posture (the fixture PEM sealed, decrypt-at-dial).
fn alpha_key_source() -> Arc<dyn alkssh::KeySource> {
let vault = TestVault::unlocked();
let blob = vault.seal(fixture("alpha"));
Arc::new(alkssh::VaultKeySource::new(vault.handle(), Some(blob)))
}
/// Dials a fresh double under the given script — one dial per row.
async fn dial_scripted_limits(
script: SshdScript,
limits: alkssh::Limits,
) -> (SshConnection, SshdDouble, SshdObserved) {
let (double, accepts, observed) =
spawn_scripted(vec![fixture("bravo").private_key()], true, script).await;
accepts.register(fixture("alpha").fingerprint);
let policy = Arc::new(MemHostKeyPolicy::with_default(true));
let mut config = DialConfig::new(
double.address.clone(),
"runner",
alpha_key_source(),
Arc::clone(&policy) as Arc<dyn alkssh::HostKeyPolicy>,
);
config.limits = limits;
let connection = dial(config).await.expect("the dial succeeds");
(connection, double, observed)
}
async fn dial_scripted(script: SshdScript) -> (SshConnection, SshdDouble, SshdObserved) {
dial_scripted_limits(script, alkssh::Limits::default()).await
}
/// The clean-close observable: the explicit close leaves the double's
/// session `Ok` — the "the connection stays usable" final act.
async fn expect_clean_close(connection: SshConnection, mut double: SshdDouble) {
assert!(!connection.is_closed());
connection.close().await;
let outcome = double.next_session_outcome().await;
assert!(
matches!(outcome, Some(Ok(()))),
"the clean close is the double's clean session end, got {outcome:?}"
);
double.shutdown();
}
/// A free loopback port the row's listen asks for exactly (the probe
/// binds, then frees — a tiny race, as everywhere a fixed port is
/// picked; the rows mostly mint dynamic ports instead).
async fn pick_port() -> u16 {
let probe = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the probe binds");
probe.local_addr().expect("addr").port()
}
/// The byte round trip the acceptor double's echo pump runs — the
/// data leg's plain duplex (the bytes back through the accepted
/// channel; the pump is the assembly's).
async fn echo_pump(mut stream: alkssh::TunnelChannel) {
let mut buf = vec![0u8; 4096];
loop {
match stream.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(count) => {
if stream.write_all(&buf[..count]).await.is_err() {
return;
}
}
}
}
}
/// What the acceptor double records per accepted open — the payload's
/// echo/originator fields (the duplex rides the type, never recorded).
#[derive(Clone, Debug, PartialEq)]
struct OpenRecord {
connected_address: String,
connected_port: u32,
originator_address: String,
originator_port: u32,
}
/// The acceptor double — records the payload's fields and, when the
/// row asks, pumps the duplex as an echo (the family handler shape:
/// the assembly owns the pump).
#[derive(Clone)]
struct AcceptorDouble {
opens: tokio::sync::mpsc::UnboundedSender<OpenRecord>,
echo: bool,
}
#[async_trait]
impl TcpipForwardedAcceptor for AcceptorDouble {
async fn accept(&self, open: ForwardedTcpip) {
let record = OpenRecord {
connected_address: open.connected_address.clone(),
connected_port: open.connected_port,
originator_address: open.originator_address.clone(),
originator_port: open.originator_port,
};
if self.echo {
tokio::spawn(echo_pump(open.stream));
} else {
drop(open.stream);
}
let _ = self.opens.send(record);
}
}
/// The streamlocal acceptor double — the unix slot's shape.
#[derive(Clone)]
struct UnixAcceptorDouble {
opens: tokio::sync::mpsc::UnboundedSender<String>,
echo: bool,
}
#[async_trait]
impl StreamlocalForwardedAcceptor for UnixAcceptorDouble {
async fn accept(&self, open: ForwardedStreamlocal) {
if self.echo {
tokio::spawn(echo_pump(open.stream));
} else {
drop(open.stream);
}
let _ = self.opens.send(open.socket_path);
}
}
/// The next accepted open's fields — the row's payload observation
/// (bounded await: a wrong row must not hang).
async fn next_open(
opens: &mut tokio::sync::mpsc::UnboundedReceiver<OpenRecord>,
context: &str,
) -> OpenRecord {
tokio::time::timeout(Duration::from_secs(15), opens.recv())
.await
.unwrap_or_else(|_| panic!("{context}: the accepted open hands off in time"))
.expect("{context}: the acceptor sender lives")
}
/// - *Contract stamp:* `client.md` §op rows row 4 (the `-R` leg's
/// listen + ingest row) + the module docs' listen-gate record —
/// featured + registered → served: the
/// `reverse_listen` request rides the wire (the double's
/// `tcpip-forward` hook observes it verbatim), the actually-bound
/// port returns, and the **data leg** round-trips: a connection to
/// the bound port's bytes flow through the double's accept loop →
/// the server-initiated `forwarded-tcpip` open → the registered
/// acceptor's duplex → the echo pump → back, verbatim. The payload
/// fields are the wire's echo shapes (the connected address/port ==
/// the listen request's, the originator the arrival's peer).
#[tokio::test]
async fn reverse_listen_serves_and_the_data_leg_pumps() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, double, observed) = dial_scripted(scripted).await;
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
let registry: &AcceptorRegistry = connection.acceptors();
registry
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: true,
}))
.expect("the slot installs");
let port = u32::from(pick_port().await);
let bound = connection
.reverse_listen("127.0.0.1", port)
.await
.expect("the featured + registered listen serves");
assert_eq!(bound, port, "an explicit port binds exactly that port");
let mut conn =
tokio::net::TcpStream::connect(("127.0.0.1", u16::try_from(port).expect("in-range")))
.await
.expect("the double's real listener accepts");
conn.write_all(b"leg-data")
.await
.expect("the forward's ingress carries the bytes");
let mut echoed = [0u8; 8];
tokio::time::timeout(Duration::from_secs(15), conn.read_exact(&mut echoed))
.await
.expect("the round trip completes in time")
.expect("the echo reads");
assert_eq!(&echoed, b"leg-data", "the data leg round-trips verbatim");
let record = next_open(&mut opens_rx, "the data-leg row").await;
assert_eq!(record.connected_address, "127.0.0.1".to_string());
assert_eq!(record.connected_port, port);
assert_eq!(record.originator_address, "127.0.0.1".to_string());
assert!(
record.originator_port > 0,
"the originator is the arrival's ephemeral port"
);
assert_eq!(
observed.listens(),
vec![("tcpip-forward", "127.0.0.1".to_string(), port)],
"the listen request rode the wire verbatim"
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* `client.md` §Lifecycle primitives (the
/// bound-port spec) — `port = 0` mint: the peer picks and the
/// surfaced number is the actually-bound one (the row connects to
/// it; the double's listener is the real socket, so the mint is the
/// real shape sshd's `get_local_port` parity rides).
#[tokio::test]
async fn port_zero_mint_surfaces_the_actually_bound_port() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, _double, _observed) = dial_scripted(scripted).await;
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
connection
.acceptors()
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the slot installs");
let bound = connection
.reverse_listen("127.0.0.1", 0)
.await
.expect("the mint serves");
assert!(
bound > 0,
"the served mint is the actually-bound port, never the requested 0"
);
drop(
tokio::net::TcpStream::connect(("127.0.0.1", u16::try_from(bound).expect("in-range")))
.await
.expect("the surfaced number is a really listening port"),
);
let record = next_open(&mut opens_rx, "the mint row").await;
assert_eq!(record.connected_address, "127.0.0.1");
assert_eq!(
record.connected_port, bound,
"the open echoes the bound port"
);
let _ = connection.close().await;
}
/// - *Contract stamp:* ADR-003 §4's issuing-side mirror + the module
/// docs' listen-gate record — a reverse listen with no registered
/// acceptor declines **before any wire op** (`NoAcceptor`; the
/// half-capability posture: a bound-but-unserveable listener is
/// never asked for), and the connection stays usable.
#[tokio::test]
async fn reverse_listen_without_an_acceptor_declines_before_any_wire_op() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, double, observed) = dial_scripted(scripted).await;
let result = connection
.reverse_listen("127.0.0.1", u32::from(pick_port().await))
.await;
assert!(
matches!(result, Err(OpError::NoAcceptor)),
"the unregistered class answers the listen, got {result:?}"
);
assert!(
observed.listens().is_empty(),
"nothing rode the wire — the gate answered before the request"
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* ADR-003 §4 — featured + unregistered → clean
/// decline: a server-initiated `forwarded-tcpip` open on a
/// live connection with an empty registry answers
/// `AdministrativelyProhibited` (never russh's accept-and-drop
/// default, never a buffered open), the decline event rides the
/// crate vocabulary, and the connection stays usable.
#[tokio::test]
async fn unregistered_forwarded_open_declines_cleanly() {
common::event_capture::install();
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, mut double, _observed) = dial_scripted(scripted).await;
let server = double.session_handle().await;
let outcome = tokio::time::timeout(
Duration::from_secs(15),
server.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0),
)
.await
.expect("the open resolves in time");
assert!(
matches!(
outcome,
Err(russh::Error::ChannelOpenFailure(
russh::ChannelOpenFailure::AdministrativelyProhibited
))
),
"the unregistered open declines cleanly, got {outcome:?}"
);
let decline = common::event_capture::await_event(&[
("row", "forwarded-tcpip"),
("decline_class", "open"),
("reason", "no-acceptor"),
])
.await;
assert_eq!(decline.level, "DEBUG");
assert_eq!(
decline.field("feature_build"),
Some(common::event_capture::expected_build())
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* the late-install dynamic (reverse-runner fact
/// 2 — consumers turn capabilities on/off per connection) + the
/// arrival-time gate — the same live connection: the pre-install
/// arrival declines, the post-install arrival serves (never
/// buffered, never half-served), and the acceptor double's late
/// install is the registry's whole surface.
#[tokio::test]
async fn late_install_serves_after_a_pre_install_arrival_declined() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, mut double, _observed) = dial_scripted(scripted).await;
let server = double.session_handle().await;
let pre = tokio::time::timeout(
Duration::from_secs(15),
server.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0),
)
.await
.expect("the pre-install open resolves in time");
assert!(
matches!(
pre,
Err(russh::Error::ChannelOpenFailure(
russh::ChannelOpenFailure::AdministrativelyProhibited
))
),
"the pre-install arrival declines at arrival time, got {pre:?}"
);
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
connection
.acceptors()
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the late install takes");
let post = tokio::time::timeout(
Duration::from_secs(15),
server.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0),
)
.await
.expect("the post-install open resolves in time");
assert!(
post.is_ok(),
"the post-install arrival serves, got {post:?}"
);
let record = next_open(&mut opens_rx, "the late-install row").await;
assert_eq!(
record,
OpenRecord {
connected_address: "hub.example".into(),
connected_port: 4096,
originator_address: "originator".into(),
originator_port: 0,
}
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* the late-install toggle's off half — the
/// uninstall takes the capability off mid-connection: the
/// post-uninstall arrival declines at arrival time (fail-closed;
/// the already-accepted open's channel is the acceptor's business).
#[tokio::test]
async fn uninstall_takes_the_capability_off_before_a_later_arrival() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, mut double, _observed) = dial_scripted(scripted).await;
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
connection
.acceptors()
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the slot installs");
let server = double.session_handle().await;
let served = tokio::time::timeout(
Duration::from_secs(15),
server.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0),
)
.await
.expect("the served open resolves in time");
assert!(served.is_ok(), "the installed slot serves, got {served:?}");
let record = next_open(&mut opens_rx, "the uninstall row").await;
assert_eq!(record.connected_address, "hub.example");
assert!(
connection.acceptors().uninstall_tcpip(),
"the uninstall reports the held slot"
);
let post = tokio::time::timeout(
Duration::from_secs(15),
server.channel_open_forwarded_tcpip("hub.example", 4096, "originator", 0),
)
.await
.expect("the post-uninstall open resolves in time");
assert!(
matches!(
post,
Err(russh::Error::ChannelOpenFailure(
russh::ChannelOpenFailure::AdministrativelyProhibited
))
),
"the post-uninstall arrival declines, got {post:?}"
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* `client.md` §Lifecycle primitives (every
/// listener explicitly cancellable) + the double's sshd parity —
/// the cancel tears the real listener down (the bound port
/// reconnects as refused), a cancel of an unknown listener is the
/// peer's refusal (`ListenRefused`), and the connection stays
/// usable after every cancel class.
#[tokio::test]
async fn cancel_tears_the_forward_down_and_unknown_cancels_refuse() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, double, observed) = dial_scripted(scripted).await;
let (opens_tx, _opens_rx) = tokio::sync::mpsc::unbounded_channel();
connection
.acceptors()
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the slot installs");
let port = u32::from(pick_port().await);
connection
.reverse_listen("127.0.0.1", port)
.await
.expect("the listen binds");
connection
.reverse_listen_cancel("127.0.0.1", port)
.await
.expect("the confirmed cancel");
assert_eq!(
observed.cancels(),
vec![("cancel-tcpip-forward", "127.0.0.1".to_string(), port)],
"the cancel rode the wire verbatim"
);
let mut refused = false;
for _ in 0..200 {
let reconnect =
tokio::net::TcpStream::connect(("127.0.0.1", u16::try_from(port).expect("in-range")))
.await;
match reconnect {
Ok(_) => {
tokio::time::sleep(Duration::from_millis(25)).await;
}
Err(err) if err.kind() == std::io::ErrorKind::ConnectionRefused => {
refused = true;
break;
}
Err(err) => panic!("the bound port errors, not refuses, after the cancel: {err:?}"),
}
}
assert!(
refused,
"the forward is torn down — the bound port refuses a reconnect"
);
let again = connection.reverse_listen_cancel("127.0.0.1", port).await;
assert!(
matches!(again, Err(OpError::ListenRefused)),
"the unknown listener's cancel refuses, got {again:?}"
);
assert!(
matches!(
connection
.reverse_listen_cancel("never-listened", port)
.await,
Err(OpError::ListenRefused)
),
"no match by endpoint either — the raw-string parity holds"
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* the module docs' recorded cap relation (the
/// server-side listener-counter posture mirrored) — the dedicated
/// listener count against the `max_concurrent_channels` ceiling:
/// beyond it the listen declines **before any wire op**
/// (`ListenCapExceeded`), and a confirmed cancel recovers the
/// budget.
#[tokio::test]
async fn the_listener_ceiling_bounds_the_listen_map_and_recovers_on_cancel() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let limits = alkssh::Limits {
max_concurrent_channels: 1,
..alkssh::Limits::default()
};
let (connection, double, observed) = dial_scripted_limits(scripted, limits).await;
let (opens_tx, _opens_rx) = tokio::sync::mpsc::unbounded_channel();
connection
.acceptors()
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the slot installs");
let first = u32::from(pick_port().await);
connection
.reverse_listen("127.0.0.1", first)
.await
.expect("the first listen admits");
let second = connection
.reverse_listen("127.0.0.1", u32::from(pick_port().await))
.await;
assert!(
matches!(second, Err(OpError::ListenCapExceeded)),
"beyond the ceiling the listen refuses pre-wire, got {second:?}"
);
assert_eq!(
observed.listens().len(),
1,
"the ceiling refusal consumed nothing on the wire"
);
connection
.reverse_listen_cancel("127.0.0.1", first)
.await
.expect("the cancel recovers the budget");
let third = u32::from(pick_port().await);
connection
.reverse_listen("127.0.0.1", third)
.await
.expect("the count admits again after the cancel");
assert_eq!(
observed.listens().len(),
2,
"the recovered budget's listen rode the wire"
);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* the module docs' refuse-never-replace posture
/// through the connection accessor — the second install is declined
/// (`AcceptorHeld`), the first acceptor stays served, and the
/// uninstall/uninstall-again cycle reports honestly.
#[tokio::test]
async fn the_connection_accessor_serves_the_registry_contract() {
let scripted = SshdScript {
tcp_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, _double, _observed) = dial_scripted(scripted).await;
let (opens_tx, _opens_rx) = tokio::sync::mpsc::unbounded_channel();
let registry = connection.acceptors();
registry
.install_tcpip(Arc::new(AcceptorDouble {
opens: opens_tx,
echo: false,
}))
.expect("the first install takes");
assert!(
matches!(
registry.install_tcpip(Arc::new(AcceptorDouble {
opens: tokio::sync::mpsc::unbounded_channel().0,
echo: false,
})),
Err(AcceptorHeld)
),
"the held slot refuses the second install"
);
assert!(registry.uninstall_tcpip());
assert!(!registry.uninstall_tcpip(), "the honest `false` report");
let _ = connection.close().await;
}
/// The unix `-R` rows (the runtime-gate's on-Unix leg's shape): the
/// listen + the data-leg round trip + the payload's echoed path + the
/// cancel's wire-honest teardown + the unknown cancel's refusal.
///
/// - *Contract stamp:* OQ-11's client mirror (the
/// `streamlocal_forward` rows compile under `tunnel`, serve on
/// unix) + `mapping.md` §Global-request rows (the unix listen legs)
/// + the acceptor registry's streamlocal slot (featured +
/// registered → served).
#[cfg(unix)]
#[tokio::test]
async fn streamlocal_listen_serves_arrivals_and_cancels_through_the_unix_gate() {
let scripted = SshdScript {
unix_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, double, observed) = dial_scripted(scripted).await;
let (ops_tx, mut ops_rx) = tokio::sync::mpsc::unbounded_channel();
let registry = connection.acceptors();
registry
.install_streamlocal(Arc::new(UnixAcceptorDouble {
opens: ops_tx,
echo: true,
}))
.expect("the streamlocal slot installs");
let socket_path = std::env::temp_dir()
.join(format!("alkssh-w4-rl-{}", std::process::id()))
.to_path_buf();
let socket_path = socket_path.to_string_lossy().to_string();
let _ = std::fs::remove_file(&socket_path);
connection
.reverse_listen_unix(&socket_path)
.await
.expect("the unix listen serves");
assert_eq!(
observed.listens(),
vec![("streamlocal-forward", socket_path.clone(), 0)],
"the unix listen rode the wire verbatim"
);
let mut conn = tokio::net::UnixStream::connect(&socket_path)
.await
.expect("the double's real listener accepts");
conn.write_all(b"unix-data")
.await
.expect("the unix forward's ingress carries the bytes");
let mut echoed = [0u8; 9];
tokio::time::timeout(Duration::from_secs(15), conn.read_exact(&mut echoed))
.await
.expect("the unix round trip completes in time")
.expect("the echo reads");
assert_eq!(
&echoed, b"unix-data",
"the unix data leg round-trips verbatim"
);
let path = tokio::time::timeout(Duration::from_secs(15), ops_rx.recv())
.await
.expect("the streamlocal handoff arrives in time")
.expect("the acceptor sender lives");
assert_eq!(
path, socket_path,
"the open echoes the listen's socket path verbatim"
);
connection
.reverse_listen_unix_cancel(&socket_path)
.await
.expect("the unix cancel confirms");
assert_eq!(
observed.cancels(),
vec![("cancel-streamlocal-forward", socket_path.clone(), 0)],
"the unix cancel rode the wire verbatim"
);
let reconnect = tokio::net::UnixStream::connect(&socket_path).await;
assert!(
reconnect.is_err(),
"the unix forward is torn down — the socket path refuses a reconnect, got {reconnect:?}"
);
assert!(
matches!(
connection.reverse_listen_unix_cancel(&socket_path).await,
Err(OpError::ListenRefused)
),
"the unknown unix listener's cancel refuses"
);
let _ = std::fs::remove_file(&socket_path);
expect_clean_close(connection, double).await;
}
/// - *Contract stamp:* OQ-11 (the runtime-Unix gate; universal
/// compile, clean off-Unix decline) + the module docs' gate record —
/// off-Unix the unix listen declines **before any wire op**
/// (`UnixUnavailable`; the platform-refused request consumes
/// nothing).
#[cfg(not(unix))]
#[tokio::test]
async fn streamlocal_declines_off_unix_before_any_wire_op() {
let scripted = SshdScript {
unix_listen: DblListen::Accept,
..SshdScript::default()
};
let (connection, double, observed) = dial_scripted(scripted).await;
connection
.acceptors()
.install_streamlocal(Arc::new(UnixAcceptorDouble {
opens: tokio::sync::mpsc::unbounded_channel().0,
echo: false,
}))
.expect("the slot installs");
let result = connection.reverse_listen_unix("alkssh-row.sock").await;
assert!(
matches!(result, Err(OpError::UnixUnavailable)),
"the off-Unix gate answers the unix listen, got {result:?}"
);
assert!(
observed.listens().is_empty(),
"nothing rode the wire — the gate answered before the request"
);
expect_clean_close(connection, double).await;
}
+267 -1
View File
@@ -56,6 +56,24 @@
//! changes/signals the client rows assert the wire verbatim
//! against). The double stays vanilla-parity: a hand-rolled
//! responder standing in for `sshd`, nothing alkssh-typed inside.
//!
//! - **The `-R` server half** (`w4-reverse-listen`): the double grows
//! the reverse-forward surfaces a vanilla `sshd` really serves — the
//! `tcpip-forward` / `streamlocal-forward` global-request hooks
//! (scripted accept/deny; an accept **binds a real socket** in
//! sshd's role, so a `port = 0` request's minted bound port is the
//! socket's real one and the data leg is a genuine
//! `ssh -R`-shaped round trip), the accept loop per bound listener
//! (per accepted connection: a server-initiated `forwarded-*` open
//! toward the client echoing the listen's own args + the
//! connection's peer as the originator, then the bytes pumped
//! bidirectionally; a client-declined open closes the accepted
//! connection — vanilla parity), and the cancel forms removing the
//! matching entry (tearing the real listener down; an unknown
//! listener denies, sshd parity). The bookkeeping is per-connection
//! handler state and bounded by the scripted listens alone — the
//! double is a scripted stand-in, not a real server: at most one
//! forward per scripted accept is what the rows drive.
#![allow(dead_code)]
@@ -89,6 +107,27 @@ pub enum DblDirect {
Echo,
}
/// The double's scripted behavior for the client's reverse-forward
/// listen requests (`tcpip-forward` / `streamlocal-forward@…) — the
/// `-R` rows' scripted class (`w4-reverse-listen`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DblListen {
/// Deny the listen — the vanilla sshd's refusal (the scripted
/// server-refusal class; the wire `RequestFailure` the client's
/// op row mirrors). The script's default (fail-closed double).
#[default]
Deny,
/// Accept the listen: the double binds a **real socket** in
/// sshd's role (the actually-bound port rides the reply's mint —
/// the port-0 row's real shape), records it for the cancel forms,
/// and spawns the accept loop (per accepted connection: a
/// server-initiated `forwarded-*` open toward the client echoing
/// the listen's own args + the connection's peer, then the bytes
/// pumped bidirectionally; a client-declined open closes that
/// accepted connection — vanilla parity).
Accept,
}
/// The fake `sshd`'s op-row script (the `w4-op-rows` request
/// surfaces) — what the double answers and, on a success, what the
/// scripted "program" produces.
@@ -110,6 +149,11 @@ pub struct SshdScript {
pub direct: DblDirect,
/// The `direct-streamlocal` opens' behavior.
pub streamlocal: DblDirect,
/// The `tcpip-forward` listen form's behavior (`tcp -R` rows).
pub tcp_listen: DblListen,
/// The `streamlocal-forward` listen form's behavior (the unix
/// `-R` rows).
pub unix_listen: DblListen,
/// The counted refusal — the double declines the *first N* client
/// session opens (per connection, armed by
/// `accept_session_channels`): the stays-usable-after-a-decline
@@ -121,7 +165,8 @@ pub struct SshdScript {
impl Default for SshdScript {
/// The plain dial rows' posture: session opens accepted (the
/// `accept_session_channels` flag governs), requests succeed with
/// no outputs and no exit, direct opens rejected.
/// no outputs and no exit, direct opens rejected, listen forms
/// denied (the scripted fail-closed default).
fn default() -> Self {
Self {
request_success: true,
@@ -130,11 +175,18 @@ impl Default for SshdScript {
exit: None,
direct: DblDirect::Reject,
streamlocal: DblDirect::Reject,
tcp_listen: DblListen::Deny,
unix_listen: DblListen::Deny,
reject_first_session_opens: 0,
}
}
}
/// One recorded global-request listen/cancel form: (the request
/// name, the wire endpoint string, the wire port) — the reverse-listen
/// rows assert the listens the client ops put on the wire.
pub type ListenRecord = (&'static str, String, u32);
/// The double's per-connection observations — the wire rows assert
/// the verbatim translations against these (what the client's op
/// rows put on the wire).
@@ -145,6 +197,8 @@ pub struct SshdObserved {
shells: Arc<Mutex<Vec<()>>>,
windows: Arc<Mutex<Vec<WindowRecord>>>,
signals: Arc<Mutex<Vec<String>>>,
listens: Arc<Mutex<Vec<ListenRecord>>>,
cancels: Arc<Mutex<Vec<ListenRecord>>>,
}
/// One recorded pty request's translated shapes — (term, cols, rows,
@@ -185,6 +239,17 @@ impl SshdObserved {
pub fn signals(&self) -> Vec<String> {
self.signals.lock().clone()
}
/// The global-request listen forms seen (name, endpoint, port —
/// the requested port, never the mint).
pub fn listens(&self) -> Vec<ListenRecord> {
self.listens.lock().clone()
}
/// The global-request cancel forms seen (name, endpoint, port).
pub fn cancels(&self) -> Vec<ListenRecord> {
self.cancels.lock().clone()
}
}
/// The double's scripted program serve — after a request's success
@@ -244,6 +309,81 @@ async fn direct_echo(mut stream: russh::ChannelStream<Msg>) {
}
}
/// The `-R` accept loop per bound listener (the double's sshd role):
/// per accepted connection a server-initiated `forwarded-*` open
/// toward the client — the connected address echoes the listen
/// request's wire string verbatim, the port the actually-bound one,
/// the originator the accepted connection's peer — then the bytes
/// pumped bidirectionally between the SSH channel and the accepted
/// socket. A client-declined open closes that accepted connection
/// (vanilla parity: the listener stays, the data has nowhere to go).
/// The loop ends with the listener itself (cancel tears it down; the
/// session's end fails the opens' sends).
async fn accept_forward_loop(
handle: Handle,
listener: tokio::net::TcpListener,
connected_address: String,
connected_port: u32,
) {
loop {
let Ok((conn, peer)) = listener.accept().await else {
return;
};
let opened = handle
.channel_open_forwarded_tcpip(
connected_address.clone(),
connected_port,
peer.ip().to_string(),
u32::from(peer.port()),
)
.await;
match opened {
Ok(channel) => {
tokio::spawn(forward_connection(channel, conn));
}
Err(_) => drop(conn),
}
}
}
/// The accepted forward connection's pump (its SSH channel ↔ its
/// socket) — the data leg's byte plane.
async fn forward_connection(channel: Channel<Msg>, mut conn: tokio::net::TcpStream) {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut conn).await;
}
/// The unix accept loop's twin (the unix `-R` rows' shape): per
/// accepted socket a `forwarded-streamlocal` open toward the client
/// echoing the listen path.
#[cfg(unix)]
async fn accept_unix_forward_loop(
handle: Handle,
listener: tokio::net::UnixListener,
socket_path: String,
) {
loop {
let Ok((conn, _)) = listener.accept().await else {
return;
};
let opened = handle
.channel_open_forwarded_streamlocal(socket_path.clone())
.await;
match opened {
Ok(channel) => {
tokio::spawn(unix_forward_connection(channel, conn));
}
Err(_) => drop(conn),
}
}
}
#[cfg(unix)]
async fn unix_forward_connection(channel: Channel<Msg>, mut conn: tokio::net::UnixStream) {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut conn).await;
}
/// The fake `sshd`'s per-connection handler — accept-set identity
/// resolution, explicit rejects everywhere, the `auth_succeeded`
/// handle carrier and the session-open acceptor stub.
@@ -255,6 +395,13 @@ struct SshdDoubleHandler {
script: Arc<SshdScript>,
observed: SshdObserved,
remaining_declines: std::sync::Arc<std::sync::atomic::AtomicUsize>,
/// The `-R` bookkeeping (per-connection, sshd's listener table):
/// the key is the cancel-form's match key (the wire endpoint + the
/// bound port — a `port = 0` mint's cancel matches the bound one),
/// the value the live accept loop (cancel aborts it, dropping the
/// real listener with it).
tcp_forwards: std::collections::HashMap<(String, u32), tokio::task::JoinHandle<()>>,
unix_forwards: std::collections::HashMap<String, tokio::task::JoinHandle<()>>,
}
impl russh::server::Handler for SshdDoubleHandler {
@@ -485,6 +632,123 @@ impl russh::server::Handler for SshdDoubleHandler {
self.observed.signals.lock().push(format!("{signal:?}"));
Ok(())
}
/// The `tcpip-forward` listen form: recorded, scripted — an accept
/// binds a real `127.0.0.1` listener in sshd's role (a `port = 0`
/// request's mint is the socket's real bound port, the out-param
/// parity), keys the listener by (wire address, bound port), and
/// spawns the accept loop ([`accept_forward_loop`]).
async fn tcpip_forward(
&mut self,
address: &str,
port: &mut u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
self.observed
.listens
.lock()
.push(("tcpip-forward", address.to_string(), *port));
if self.script.tcp_listen == DblListen::Deny {
return Ok(false);
}
let bind_target = format!("127.0.0.1:{}", *port);
let Ok(listener) = tokio::net::TcpListener::bind(&bind_target).await else {
return Ok(false);
};
let bound = listener
.local_addr()
.expect("the bound listener has an address")
.port();
let bound = u32::from(bound);
if *port == 0 {
*port = bound;
}
let task = tokio::spawn(accept_forward_loop(
session.handle(),
listener,
address.to_string(),
bound,
));
self.tcp_forwards.insert((address.to_string(), bound), task);
Ok(true)
}
/// The `cancel-tcpip-forward` form: matches by the raw wire
/// strings (the bound port for a mint); a hit removes the
/// listener (tearing the real socket down — the cancel row's
/// observable), an unknown listener denies (sshd parity).
async fn cancel_tcpip_forward(
&mut self,
address: &str,
port: u32,
_session: &mut Session,
) -> Result<bool, Self::Error> {
self.observed
.cancels
.lock()
.push(("cancel-tcpip-forward", address.to_string(), port));
match self.tcp_forwards.remove(&(address.to_string(), port)) {
Some(listener_task) => {
listener_task.abort();
Ok(true)
}
None => Ok(false),
}
}
/// The `streamlocal-forward` listen form (the unix `-R`): records,
/// scripts, binds the real unix socket (stale file unlinked
/// first), and spawns the unix accept loop. Off-Unix the hook is
/// not implemented and falls to the trait's deny default (the
/// vanilla Windows sshd parity) — the crate's own runtime-Unix
/// gate mirrors the refusal client-side before the wire.
#[cfg(unix)]
async fn streamlocal_forward(
&mut self,
socket_path: &str,
session: &mut Session,
) -> Result<bool, Self::Error> {
self.observed
.listens
.lock()
.push(("streamlocal-forward", socket_path.to_string(), 0));
if self.script.unix_listen == DblListen::Deny {
return Ok(false);
}
let _ = std::fs::remove_file(socket_path);
let Ok(listener) = tokio::net::UnixListener::bind(socket_path) else {
return Ok(false);
};
let task = tokio::spawn(accept_unix_forward_loop(
session.handle(),
listener,
socket_path.to_string(),
));
self.unix_forwards.insert(socket_path.to_string(), task);
Ok(true)
}
/// The `cancel-streamlocal-forward` form (matches by the path
/// alone — the unix cancel's sshd parity).
#[cfg(unix)]
async fn cancel_streamlocal_forward(
&mut self,
socket_path: &str,
_session: &mut Session,
) -> Result<bool, Self::Error> {
self.observed.cancels.lock().push((
"cancel-streamlocal-forward",
socket_path.to_string(),
0,
));
match self.unix_forwards.remove(socket_path) {
Some(listener_task) => {
listener_task.abort();
Ok(true)
}
None => Ok(false),
}
}
}
impl SshdDoubleHandler {
@@ -589,6 +853,8 @@ pub async fn spawn_scripted(
remaining_declines: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
script_declines,
)),
tcp_forwards: std::collections::HashMap::new(),
unix_forwards: std::collections::HashMap::new(),
};
let outcomes = outcomes_tx.clone();
tokio::spawn(async move {
+160
View File
@@ -1894,6 +1894,166 @@ mod serving {
}
}
#[cfg(feature = "tunnel")]
mod client_smoke {
//! The full-circle smoke (`w4-reverse-listen`'s recorded posture):
//! the **alkssh client's** `-R` rows against the **alkssh server
//! direction's** `-R` rows — both directions of the crate in one
//! live wire, the route-1 bridge's two legs meeting through the
//! alkcall fabric double (the composition the alkforge assembly
//! makes). A connection to the minted bound port round-trips
//! through: the fabric's real TCP listener → the fabric's
//! bind-first accept loop → the door-side `tunnel/forwarded`
//! arrival → the door's server-initiated `forwarded-tcpip` open
//! (echo args + originator) → the alkssh client's acceptor registry
//! → the acceptor duplex → the echo back. Real-`sshd` `-R` is
//! wave-5 e2e territory (never silently dropped).
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use alkssh::{
AcceptorRegistry, DialConfig, ForwardedTcpip, TcpipForwardedAcceptor, TunnelListenSeam,
dial,
};
use super::common::harness::serve_config as harness_serve_config;
use super::common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
use super::serving::wire_fabric_with_listen;
struct EchoAcceptor {
opens: tokio::sync::mpsc::UnboundedSender<(String, u32, String)>,
}
#[async_trait]
impl TcpipForwardedAcceptor for EchoAcceptor {
async fn accept(&self, open: ForwardedTcpip) {
let _ = self.opens.send((
open.connected_address.clone(),
open.connected_port,
open.originator_address.clone(),
));
tokio::spawn(async move {
let mut stream = open.stream;
let mut buf = vec![0u8; 4096];
loop {
match stream.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(count) => {
if stream.write_all(&buf[..count]).await.is_err() {
return;
}
}
}
}
});
}
}
#[tokio::test]
async fn smoke_client_reverse_listen_full_circle_through_the_server_direction() {
let double = wire_fabric_with_listen(true, true).await;
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("reverse-runner", &["ssh"]),
);
let mut serve = harness_serve_config(provider, &[fixture("bravo")], Default::default());
serve.channel_client = Some(Arc::clone(&double.client));
serve.tunnel_listen_seam = Some(TunnelListenSeam::new(Arc::clone(&double.registry)));
let adapter = alkssh::SshAdapter::new(serve)
.await
.expect("the -R wired adapter constructs");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the smoke's loopback listener binds");
let addr = listener.local_addr().expect("the smoke has an address");
tokio::spawn(async move {
loop {
let Ok((socket, _)) = listener.accept().await else {
break;
};
if adapter.handle(socket).await.is_err() {
break;
}
}
});
let policy = Arc::new(MemHostKeyPolicy::new());
policy.pin("127.0.0.1", fixture("bravo").fingerprint);
let config = DialConfig::new(
addr.to_string(),
"runner",
crate_test_key_source(),
Arc::clone(&policy) as Arc<dyn alkssh::HostKeyPolicy>,
);
let connection = dial(config).await.expect("the dial succeeds");
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
let registry: &AcceptorRegistry = connection.acceptors();
registry
.install_tcpip(Arc::new(EchoAcceptor { opens: opens_tx }))
.expect("the acceptor installs");
let bound = connection
.reverse_listen("127.0.0.1", 0)
.await
.expect("the full-circle mint serves");
assert!(
bound > 0,
"the server direction's fabric minted a real bound port"
);
let mut conn =
tokio::net::TcpStream::connect(("127.0.0.1", u16::try_from(bound).expect("in-range")))
.await
.expect("the fabric's listener accepts the arrival");
conn.write_all(b"full-circle-data")
.await
.expect("the arrival carries the bytes");
let mut echoed = [0u8; 16];
tokio::time::timeout(Duration::from_secs(30), conn.read_exact(&mut echoed))
.await
.expect("the full-circle round trip completes in time")
.expect("the echo reads");
assert_eq!(
&echoed, b"full-circle-data",
"the bytes returned through every leg: fabric → door arrival \
→ SSH channel → client acceptor duplex → echo → back"
);
let (connected_address, connected_port, originator_address) =
tokio::time::timeout(Duration::from_secs(15), opens_rx.recv())
.await
.expect("the acceptor's handoff record arrives")
.expect("the acceptor sender lives");
assert_eq!(connected_address, "127.0.0.1");
assert_eq!(
connected_port, bound,
"the echo port is the minted bound one"
);
assert_eq!(
originator_address, "127.0.0.1",
"the originator carries the fabric arrival's peer"
);
let _ = connection.close().await;
let _ = double.minted_token().await; // the establisher's mint observed (the fabric's bookkeeping)
}
/// The smoke's dial key pose (the TestVault posture, client-side).
fn crate_test_key_source() -> Arc<dyn alkssh::KeySource> {
let vault = super::common::TestVault::unlocked();
let blob = vault.seal(fixture("alpha"));
Arc::new(alkssh::VaultKeySource::new(vault.handle(), Some(blob)))
}
}
#[cfg(all(feature = "tunnel", unix))]
mod real_tool {
//! The real-tool smoke (`w3-tunnel-direct`): one `ssh -L` (+ curl)