- 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).
625 lines
26 KiB
Rust
625 lines
26 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|