feat(tunnel): land the -R listen flow + the forwarded-tcpip data leg (w3-tunnel-listen)
The tunnel branch's other half is served for real against published alktunnels 0.4.1 / alkcall 0.10.1, with POC-3 §C.4 as the template: - tcpip_forward/streamlocal_forward (+ both cancel forms) map the vanilla listen request family-ward through TunnelListener::open on the held ChannelClient (bounded inline dial — the global request's reply + the &mut u32 bound-port out-param are the hook's); the wire bind endpoint rides as the resource string verbatim (OpenSSH client-side normalization parity). A reply without the minted listen token/bound report declines and reaps its fabric listener. - the door serves the fabric's inbound tunnel/forwarded opens: the serve config gains TunnelListenSeam (the seam-config second course-correction — the shared ServingConfig registry the assembly wired at from_connection_with_serving); alkssh registers the forwarded openable (ChannelCore over a clone of the held client's manager) at SshAdapter::new — a failed registration is a failed construction (AdapterError::ListenRegistration). - the forwarded-tcpip data leg: token correlation through the shared ListenRouter → the in-crate per-session channel cap at the accept surface (the acceptor vocabulary) → Handle:: channel_open_forwarded_tcpip streaming the listen request's own args + the actually-bound port (sshd parity) with TunnelForwardedParams.peer as the originator → pump_bidi(ssh, fabric). Correlation-miss → teardown-at-birth, never a half-open SSH channel. Unix arrivals ride forwarded-streamlocal@openssh.com. - bounded bookkeeping: the per-session listener map packet-loop-only with a dedicated max_concurrent_channels-ceiling counter; the route map bounded by construction (mints at accepted listens, removed at cancels + session teardown — the handler's Drop). - the harness grew the client-side accept hook (server_channel_open_forwarded_tcpip/_streamlocal, acceptor-driven) + a hand-rolled tracing subscriber; 18 contract rows + both real-tool smokes (ssh -L, ssh -R + curl) green in tests/tunnel_mapping.rs; the wave-2 stub listen rows migrated per build (unfeatured / seam-unconfigured). Verified: cargo test across default/git/session/sftp/tunnel and all combos + --all-features; clippy -D warnings (default, all-features, combos); cargo fmt --check; cargo doc --no-deps clean; taskgraph validate (26 tasks).
This commit is contained in:
1 parent
6443feac3e
commit
3d0253d981
15 files changed
+2839
-236
No files matched your search
@@ -91,6 +91,11 @@ gix-hash = { version = "0.28", features = ["sha1"] }
|
||||
# `ReceiverStream` — test-graph only (the crate proper hand-rolls no
|
||||
# streams).
|
||||
tokio-stream = "0.1"
|
||||
# The leg-2 fabric double's establisher/params serialization
|
||||
# (`w3-tunnel-listen`) — test-graph only (the door's own lib code
|
||||
# stays serde-free under the `tunnel` feature; the double's
|
||||
# dynamic-bind producer serializes params/reply fields).
|
||||
serde_json = "1"
|
||||
# The real-tool pty smoke (`w3-tty-session`): alktty's `local` backend
|
||||
# composition — the dev-dep `features = ["local"]` unification applies
|
||||
# to the TEST graph only (feature unification folds `local` into the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: reviewed
|
||||
last_updated: 2026-10-28
|
||||
last_updated: 2026-10-29
|
||||
---
|
||||
|
||||
# Mapping — server direction: SSH channels × family seams (the spine)
|
||||
@@ -34,7 +34,7 @@ bridge primitives every mapping uses (POC-1 §7).
|
||||
| `session` → `exec_request` git | `git clone/push ssh://` | `git` | alkgit: parse command → `Service` + repo → `serve_duplex_resolved(repo_id, identity, service, duplex, …)` | **exec-only** (ADR-006 decision 2): the repo rides inside the exec command string (`git-upload-pack '/repo'`); vanilla git never speaks subsystem for git; process-mode dialect (server-speaks-first advertisement); exit-status proven both directions |
|
||||
| `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | — | **`sftp` name**: declines until alkfs lands — the feature exists, the route doesn't (OQ-13 resolved: the tested decline placeholder is the shipped behavior until an alkfs-mapping ADR changes it). **git service names**: decline with reason — a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves |
|
||||
| `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge — `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds — failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) |
|
||||
| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable`'s `ForwardedHandler` shape — hand the channel duplex to the handler | listen flow = the global-request rows below; the data leg is untested in POC-3 beyond refusal (see the POC-3 note below) |
|
||||
| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable`'s `ForwardedHandler` shape — hand the channel duplex to the handler | per fabric arrival the door opens a `forwarded-tcpip` channel **toward the vanilla client** (a vanilla client never initiates this type — sshd parity; an inbound open is the unvanilla class, declined `ConnectFailed`); echo args = the original `tcpip_forward` request's wire string verbatim + the actually-bound port (the sshd `c->path` + `get_local_port` parity — a `port = 0` mint echoes the allocated number); originator = `TunnelForwardedParams.peer`; correlation-miss → teardown-at-birth (never a half-open channel); the accept is the crate's per-session channel cap (the acceptor vocabulary) |
|
||||
| `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | compiles wherever `tunnel` compiles; serving is **runtime-Unix-gated** — on Unix serves, on other platforms a clean open-decline (OQ-11 resolved: universal compile + runtime decline, no per-platform feature fork) |
|
||||
| `x11` | X forwarding | — (never) | — | always declines; not feature-gated (ADR-003 §5) |
|
||||
| `auth-agent` | agent forwarding | — (never) | — | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) |
|
||||
@@ -56,14 +56,67 @@ command → parse-then-fail).
|
||||
## Global-request rows (the `-R` listen flow)
|
||||
|
||||
`Handler::tcpip_forward(address, &mut port) -> bool` +
|
||||
`Handler::streamlocal_forward` — gate on `tunnel` (decline both the
|
||||
listen request and the data channels when absent). `streamlocal_forward`
|
||||
additionally carries the same runtime-Unix gate as its channel row
|
||||
(OQ-11): off-Unix it declines. The bound-port
|
||||
out-param (`port = 0` → server picks) is a spec detail in
|
||||
`client.md`'s lifecycle primitives; disposition follows `tunnel`.
|
||||
*POC-3 note: only the refusal shape was exercised (egress-only build);
|
||||
the served listen flow is implementation-phase contract-test territory.*
|
||||
`Handler::streamlocal_forward` (+ both cancel forms) — gate on
|
||||
`tunnel` (unfeatured declines both the listen requests and the data
|
||||
channels). The served flow (`w3-tunnel-listen`, decidable against
|
||||
alktunnels 0.4.1's actual surfaces with POC-3 §C.4 as the template):
|
||||
|
||||
- **The listen dial** — family-ward through the consumer surface:
|
||||
`TunnelListener::open(channel_client, TunnelParams {resource: <the
|
||||
wire bind endpoint>, substrate: Tcp/Unix})`. The resource carries
|
||||
the wire endpoint string verbatim — the OpenSSH client normalizes
|
||||
the no-address case to `"localhost"` before it sends
|
||||
(`channel_rfwd_bind_host` parity), so the door passes through; whose
|
||||
bind policy governs (wildcard vs loopback — the sshd `GatewayPorts`
|
||||
posture) is the fabric's establisher/bind closure, assembly policy
|
||||
at the family's provider. The dial is bounded (`LISTEN_DIAL_TIMEOUT`
|
||||
= 10 s, matching alkcall's own establisher deadline) and awaited
|
||||
**inline** in the hook by wire necessity (the global request's
|
||||
answer + the `&mut u32` out-param are the hook's own; the fabric
|
||||
leg's answer doesn't ride this session's routing, so the stall is
|
||||
bounded, not deadlock-class) — recorded as the flow's one inline
|
||||
serving step.
|
||||
- **The reply contract**: the minted `listen` token (alkcall ADR-049
|
||||
amendment 3 establisher field) is REQUIRED — a producer that minted
|
||||
no token (or no `bound` report) gets a declined listen with its
|
||||
fabric channel reaped immediately (a listen the door cannot
|
||||
correlate arrivals onto never answers success). The bound port
|
||||
writes the `&mut u32` out-param (`port = 0` → the actually-bound
|
||||
number — sshd's `allocated_listen_port` reply parity; the
|
||||
`client.md` lifecycle primitive).
|
||||
- **The door-side arrival surface** — the fabric's bind-first accept
|
||||
dispatcher (`make_bindfirst_accept_handler`) dials the
|
||||
`tunnel/forwarded` op **toward the door** per arrival (the
|
||||
`ForwardedOpener` closure); the door serves it through the alkcall
|
||||
openable registered into the shared per-connection registry — the
|
||||
serve-config's `TunnelListenSeam` (the `w3-seam-config` second
|
||||
course-correction: the assembly's `Arc<ChannelClient>` alone
|
||||
carries no inbound-op serving loop; the seam fronts the
|
||||
`ServingConfig.registry` the assembly wired at the leg's
|
||||
`from_connection_with_serving`). The registration runs at
|
||||
`SshAdapter::new` (a failure is a failed construction —
|
||||
`AdapterError::ListenRegistration`); alkcall's own gates carry the
|
||||
per-identity cap + the `tunnel:forwarded` scope per arrival, then
|
||||
the crate's arrival pump takes over.
|
||||
- **The arrival pump** — per the `forwarded-tcpip` row above; unix
|
||||
substrate arrivals (a minted `streamlocal_forward` route) open
|
||||
`forwarded-streamlocal@openssh.com` with the path echoed (russh's
|
||||
server `Handle` carries the channel-open at this pin; the OQ-11
|
||||
runtime-Unix gate rides the unix listen row the same way it rides
|
||||
the channel row: off-Unix decline, universal compile).
|
||||
- **The cancel forms + bounded bookkeeping**: the per-session
|
||||
listener map (packet-loop-only, like the `channels` store) matches
|
||||
cancels by (the wire endpoint as the request carried it, the bound
|
||||
port — sshd's `c->path == host && listening_port == port` parity;
|
||||
the unix forms by path alone) 1:1 into the family listener's
|
||||
teardown (`TunnelListener::close`/`Drop` — the route leaves the
|
||||
shared router *before* the teardown races), `false` for an unknown
|
||||
pair. The listen count is a bounded resource: a dedicated counter
|
||||
against the same `Limits::max_concurrent_channels` ceiling (the
|
||||
listener/channels mix stays unconstrained; both stay bounded — no
|
||||
unbounded listener map). A session's teardown removes its tokens
|
||||
from the shared router (a fabric arrival after the session is gone
|
||||
is the correlation-miss class).
|
||||
|
||||
## Request-level rows on a `session` channel
|
||||
|
||||
@@ -156,7 +209,7 @@ the whole spine, not an implementation choice.
|
||||
| none (default) | — | everything |
|
||||
| `git` | git-parseable exec | pty/shell, plain exec, all other channel types, all `session` requests |
|
||||
| `session` | pty/shell/pipe-exec (requires the configured shell/exec wrapper — OQ-08; an unset field declines its row though the feature is on) | git-parseable exec (the `git` parse is its gate), other channel types |
|
||||
| `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens | session channels (auto-reject), all others |
|
||||
| `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens + arrivals (the listen forms additionally require the configured `TunnelListenSeam` — an egress-only door declines just the `-R` forms) | session channels (auto-reject), all others |
|
||||
| any build + `sftp` slot | unchanged from the underlying build | `sftp` subsystems decline-with-reason until alkfs |
|
||||
|
||||
Every row is tested in *every* build it can appear in — the decline
|
||||
|
||||
@@ -50,6 +50,8 @@ use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use crate::config::ServeConfig;
|
||||
use crate::error::{AdapterError, KeySourceError};
|
||||
use crate::handler::SessionHandler;
|
||||
#[cfg(feature = "tunnel")]
|
||||
use crate::tunnel_map;
|
||||
|
||||
/// The constructed server stack — the substrate every channel-type
|
||||
/// mapping composes into (ADR-001's `SshAdapter`).
|
||||
@@ -70,6 +72,21 @@ impl SshAdapter {
|
||||
/// server, with the [`KeySourceError`] refused through
|
||||
/// [`AdapterError::Construction`].
|
||||
pub async fn new(serve: ServeConfig) -> Result<Self, AdapterError> {
|
||||
let serve = {
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
{
|
||||
serve
|
||||
}
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
let mut serve = serve;
|
||||
if serve.channel_client.is_some() && serve.tunnel_listen_seam.is_some() {
|
||||
tunnel_map::install_forwarded_surface(&mut serve)
|
||||
.map_err(AdapterError::ListenRegistration)?;
|
||||
}
|
||||
serve
|
||||
}
|
||||
};
|
||||
let keys = serve.host_key_source.load().await?;
|
||||
if keys.is_empty() {
|
||||
return Err(AdapterError::Construction(KeySourceError::Absent));
|
||||
|
||||
+40
-45
@@ -16,13 +16,15 @@
|
||||
//! [`crate::dispatch::cap_exceeded_open_decline`]). Release
|
||||
//! ([`ChannelCap::release`]) fires from the `channel_close` hook for
|
||||
//! established channels — and, since `w3-tunnel-direct`, from a
|
||||
//! spawned route-1 bridge task on a fabric-open failure, where the
|
||||
//! open was *admitted but never established* (the reject shape means
|
||||
//! no `CHANNEL_CLOSE` ever cycles back): see the release-sites section.
|
||||
//! spawned route-1 bridge task on a fabric-open failure — and, since
|
||||
//! `w3-tunnel-listen`, from the spawned `-R` arrival pumps on the
|
||||
//! never-established paths (client-rejected open, dead session): see
|
||||
//! the release-sites section.
|
||||
//!
|
||||
//! ## Release sites (the `w3-tunnel-direct` recorded decision)
|
||||
//! ## Release sites (the recorded decision, three sites since
|
||||
//! `w3-tunnel-listen`)
|
||||
//!
|
||||
//! Every admission has exactly one release, and the two paths are
|
||||
//! Every admission has exactly one release, and the paths are
|
||||
//! disjoint by the wire shape:
|
||||
//!
|
||||
//! 1. **The established-channel close** — the `channel_close` hook.
|
||||
@@ -31,37 +33,39 @@
|
||||
//! is consumed at most once (russh removes the channel before the
|
||||
//! hook).
|
||||
//! 2. **The admitted-but-rejected bridge** — the route-1 bridge's
|
||||
//! failure path. The wave-3 flow admits at the open hook (the
|
||||
//! packet-loop single-writer site — no race window against the cap
|
||||
//! check) and holds the count across the fabric open; on fabric
|
||||
//! failure the open is rejected with the explicit `ConnectFailed`
|
||||
//! failure path (`w3-tunnel-direct`). The open hook admits and
|
||||
//! holds the count across the fabric open; on fabric failure the
|
||||
//! open is rejected with the explicit `ConnectFailed`
|
||||
//! (ADR-006 decision 1: accept only after the fabric open
|
||||
//! succeeds), the channel never establishes, and *no `channel_close`
|
||||
//! can ever fire* — so the count must be released from where the
|
||||
//! failure is known: inside the spawned bridge task, immediately
|
||||
//! after the reject is answered. A success never releases there
|
||||
//! (its close rides path 1), so a failed bridge cannot leak budget
|
||||
//! and cannot double-release.
|
||||
//! succeeds), the channel never establishes, and *no
|
||||
//! `channel_close` can ever fire* — so the count must be released
|
||||
//! from where the failure is known: inside the spawned bridge
|
||||
//! task, immediately after the reject is answered. A success never
|
||||
//! releases there (its close rides path 1).
|
||||
//! 3. **The `-R` arrival pumps** (`w3-tunnel-listen`): a fabric
|
||||
//! arrival is admitted into the owning route's count and opens the
|
||||
//! SSH `forwarded-tcpip`/`forwarded-streamlocal` channel toward
|
||||
//! the vanilla client through the session handle. A rejected open —
|
||||
//! the client's own refusal, or a dead session's send failure —
|
||||
//! never establishes the channel, so the count releases inside the
|
||||
//! arrival task exactly once (an established channel's release is
|
||||
//! path 1's). These are the mirror of site 2 on the server-initiated
|
||||
//! open direction — the acceptor vocabulary's cap rows.
|
||||
//!
|
||||
//! ## Why the mutex (the single-writer reasoning, updated)
|
||||
//! ## Why the mutex (the single-writer reasoning, updated twice)
|
||||
//!
|
||||
//! The wave-2 shape was a bare `usize` field written only by the
|
||||
//! session's packet loop — sound because every hook runs inline on
|
||||
//! that loop with exclusive `&mut self` access. The bridge release
|
||||
//! breaks the single-writer property: the spawned task owns the
|
||||
//! failure answer and the packet loop cannot be reached from it. The
|
||||
//! recorded decision is a `parking_lot::Mutex<usize>`: **admissions
|
||||
//! stay packet-loop-only** (every `admit` call site is an
|
||||
//! `channel_open_*` hook — the cap check and the increment are one
|
||||
//! atomic section against a shared writer), while **releases have the
|
||||
//! two sites above** (packet loop + spawned bridge tasks). The mutex
|
||||
//! is poisoned-lock-resistant (`unwrap_or_else(|e| e.into_inner())` —
|
||||
//! AGENTS.md convention 2: a panic in one operation must not cascade
|
||||
//! to other operations), and both release sites saturate
|
||||
//! (`saturating_sub`), so no path can manufacture capacity with a
|
||||
//! stray decrement. Channels never closed before session teardown
|
||||
//! stay counted until the handler dies with the session — per-session
|
||||
//! state, gone with the connection.
|
||||
//! (`w3-tunnel-direct`) and the arrival admission (`w3-tunnel-listen`)
|
||||
//! both break the single-writer property: those tasks are alkcall
|
||||
//! serving tasks, unreachable from the packet loop. The recorded
|
||||
//! decision is a `parking_lot::Mutex<usize>`: **packet-loop hooks and
|
||||
//! spawned tasks both touch the count only through the mutex**, their
|
||||
//! critical sections are single integer ops (no await inside the
|
||||
//! lock), and all sites saturating. Channels never closed before
|
||||
//! session teardown stay counted until the handler dies with the
|
||||
//! session — per-session state, gone with the connection.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
@@ -76,22 +80,15 @@ pub(crate) struct ChannelCap {
|
||||
}
|
||||
|
||||
impl ChannelCap {
|
||||
/// Whether the cap has room — the check form for the rows whose
|
||||
/// decline is ordered *before* the accept decision without
|
||||
/// consuming a count: the `forwarded-tcpip` stub (the `-R` data
|
||||
/// leg's flow is wave-3 material — a declined stub opens nothing,
|
||||
/// so it must not consume an admission). The accept paths use
|
||||
/// [`ChannelCap::admit`]; a plain check never mutates.
|
||||
pub(crate) fn has_room(&self, cap: usize) -> bool {
|
||||
*self.active.lock() < cap
|
||||
}
|
||||
|
||||
/// The accept-path admission decision: room under `cap` increments
|
||||
/// the count and admits; otherwise refuses (fail-closed — the
|
||||
/// caller answers the open with a clean decline). `cap == 0`
|
||||
/// refuses every open. The caller site is the packet loop's open
|
||||
/// hook (the single admit writer — the held count is what the
|
||||
/// spawned bridge holds across the fabric open).
|
||||
/// refuses every open. Call sites: the packet loop's open hooks
|
||||
/// `w2`-style, and — since `w3-tunnel-listen` — the `-R` arrival
|
||||
/// tasks (the door's forwarded-arrival admission: an arrival is
|
||||
/// admitted on the owning route's count inside the spawned
|
||||
/// arrival pump — see the module doc's updated single-writer
|
||||
/// reasoning). The mutex makes both sound.
|
||||
pub(crate) fn admit(&self, cap: usize) -> bool {
|
||||
let mut active = self.active.lock();
|
||||
if *active < cap {
|
||||
@@ -134,7 +131,6 @@ mod tests {
|
||||
assert!(cap.admit(CAP));
|
||||
assert!(cap.admit(CAP));
|
||||
assert!(!cap.admit(CAP), "beyond the cap: refused");
|
||||
assert!(!cap.has_room(CAP));
|
||||
assert_eq!(cap.active(), CAP);
|
||||
}
|
||||
|
||||
@@ -148,7 +144,6 @@ mod tests {
|
||||
assert!(!cap.admit(CAP));
|
||||
|
||||
cap.release();
|
||||
assert!(cap.has_room(CAP));
|
||||
assert!(cap.admit(CAP), "an open succeeds again after a close");
|
||||
assert!(!cap.admit(CAP));
|
||||
}
|
||||
|
||||
+138
-7
@@ -31,9 +31,15 @@
|
||||
//! decline even though the `session` feature is on;
|
||||
//! [`ServeConfig::git_seam`] unset → the git row declines even
|
||||
//! though `git` is on (the decline matrix's config-driven notes;
|
||||
//! the feature alone configures nothing). The tunnel handle stays
|
||||
//! the flat `Option<Arc<ChannelClient>>` as landed — a single-field
|
||||
//! seam needs no struct.
|
||||
//! the feature alone configures nothing). The tunnel branch carries
|
||||
//! its flat `Option<Arc<ChannelClient>>` — an egress-only
|
||||
//! (route-1-only) door needs no struct for leg 1 — and the `-R`
|
||||
//! listen leg's registration seam landed with `w3-tunnel-listen`
|
||||
//! (`ServeConfig::tunnel_listen_seam` — the wave-3 seam-config's
|
||||
//! second course-correction; the shape decision recorded on
|
||||
//! [`TunnelListenSeam`]: unset → the listen rows decline even with
|
||||
//! a fabric leg present, because the door-side inbound-op serving
|
||||
//! loop only exists where the assembly wired it).
|
||||
//! - **Injected handles, never crate-resolved identity or trust**:
|
||||
//! the [`IdentityProvider`], [`HostKeySource`] and
|
||||
//! [`HostKeyPolicy`] handles are `Arc<dyn ..>` the assembly
|
||||
@@ -61,6 +67,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
use crate::tunnel_map::ListenRouter;
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
#[cfg(feature = "session")]
|
||||
@@ -298,6 +306,69 @@ impl std::fmt::Debug for GitSeam {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tunnel` feature's `-R` listen-leg registration seam (the
|
||||
/// `w3-tunnel-listen` shape — the wave-3 seam-config's second
|
||||
/// course-correction, resolved per its own decision record): the
|
||||
/// assembly-supplied `Arc<ChannelClient>` alone cannot carry the
|
||||
/// door-side acceptance of the fabric's inbound forwarded opens,
|
||||
/// because alkcall serves those opens on the **door's channel-0
|
||||
/// serving loop**, which exists only when the consumer constructed its
|
||||
/// `ChannelClient` with
|
||||
/// [`ChannelClient::from_connection_with_serving`] against a registry
|
||||
/// the assembly owns. The published alkcall surface has no
|
||||
/// post-construction way to add that loop, so the serve-config's
|
||||
/// tunnel handle grows this seam.
|
||||
///
|
||||
/// Same whole-`Option`-struct decision as [`SessionSeam`]/[`GitSeam`]:
|
||||
/// **`ServeConfig::tunnel_listen_seam` unset → the `-R` listen rows
|
||||
/// (`tcpip_forward` / `streamlocal_forward` / both cancel forms)
|
||||
/// decline even under the `tunnel` feature with a fabric leg present**
|
||||
/// — an egress-only door (the POC-3 stage-C posture) is legitimate
|
||||
/// config; the feature alone configures nothing. Checked at request
|
||||
/// time by the mappings; no construction-time validation. The
|
||||
/// registration itself (the alkcall openable registration the seam
|
||||
/// fronts) is alkssh-side work at [`crate::SshAdapter::new`] — a
|
||||
/// failed registration is a failed construction (fail-closed, never a
|
||||
/// half-configured adapter).
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub struct TunnelListenSeam {
|
||||
/// The **shared** per-connection `OperationRegistry` the fabric
|
||||
/// leg's inbound ops dispatch against: the exact `Arc` the
|
||||
/// assembly passed in this leg's `ChannelClient` construction
|
||||
/// (`ServingConfig.registry`) — alkssh registers the
|
||||
/// `tunnel/forwarded` openable into it, and alkcall's dispatch
|
||||
/// reads it per call (the W2 post-hoc-registration posture). The
|
||||
/// door's own ACL entry rides the registration; arrivals lacking
|
||||
/// the `tunnel:forwarded` scope are gated by alkcall's
|
||||
/// `AccessControl::check` fail-closed.
|
||||
pub registry: Arc<OperationRegistry>,
|
||||
}
|
||||
|
||||
/// The `OperationRegistry` type behind the seam's shared dispatcher.
|
||||
/// (Re-imported here for the field's shape; the registration work
|
||||
/// lives in [`crate::tunnel_map`].)
|
||||
#[cfg(feature = "tunnel")]
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
impl TunnelListenSeam {
|
||||
/// Builds the seam over the assembly's shared per-connection
|
||||
/// registry (the `ServingConfig.registry` the same fabric leg's
|
||||
/// `ChannelClient` was constructed with — one `Arc`, both sides).
|
||||
pub fn new(registry: Arc<OperationRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
impl std::fmt::Debug for TunnelListenSeam {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TunnelListenSeam")
|
||||
.field("registry", &"<shared OperationRegistry>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The server direction's assembly-supplied serving configuration
|
||||
/// (`overview.md` §Server stack): everything wave-2's serving loop
|
||||
/// takes at construction. Pure data + injected handles — no russh
|
||||
@@ -376,6 +447,20 @@ pub struct ServeConfig {
|
||||
/// 2: absent + unfeatured both decline; present + featured
|
||||
/// serves.
|
||||
pub channel_client: Option<Arc<ChannelClient>>,
|
||||
/// The `-R` listen leg's registration seam ([`TunnelListenSeam`] —
|
||||
/// the shared per-connection registry the fabric's inbound
|
||||
/// forwarded opens dispatch against). **Unset → the listen rows
|
||||
/// decline even under the `tunnel` feature with a fabric leg
|
||||
/// present** (the egress-only door is legitimate config — the
|
||||
/// POC-3 stage-C posture; shape decision recorded on the type).
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub tunnel_listen_seam: Option<TunnelListenSeam>,
|
||||
/// The listen leg's per-arrival bookkeeping — installed by
|
||||
/// [`crate::SshAdapter::new`] when the fabric leg + seam are both
|
||||
/// present (the registration site). Crate-private: never assembly
|
||||
/// data.
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub(crate) listen_router: Option<Arc<ListenRouter>>,
|
||||
/// The bounded-resources budget this serving direction enforces
|
||||
/// (see [`Limits`]).
|
||||
pub limits: Limits,
|
||||
@@ -404,6 +489,10 @@ impl ServeConfig {
|
||||
identity_provider,
|
||||
host_key_source,
|
||||
channel_client: None,
|
||||
#[cfg(feature = "tunnel")]
|
||||
tunnel_listen_seam: None,
|
||||
#[cfg(feature = "tunnel")]
|
||||
listen_router: None,
|
||||
limits: Limits::default(),
|
||||
}
|
||||
}
|
||||
@@ -431,6 +520,16 @@ impl ServeConfig {
|
||||
self.channel_client.is_some()
|
||||
}
|
||||
|
||||
/// Whether the `-R` listen rows' registration seam is configured
|
||||
/// in this configuration ([`TunnelListenSeam`] present; the
|
||||
/// `tunnel` feature gate applies at the dispatch on top): `false`
|
||||
/// means the listen rows decline — the unset-declines contract,
|
||||
/// checked at request time by the mappings, never here.
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub fn tunnel_listen_seam_configured(&self) -> bool {
|
||||
self.tunnel_listen_seam.is_some()
|
||||
}
|
||||
|
||||
/// Whether the session rows' family seam is configured in this
|
||||
/// configuration ([`SessionSeam`] present; the `session` feature
|
||||
/// gate applies at the dispatch on top): `false` means the rows
|
||||
@@ -470,10 +569,12 @@ impl std::fmt::Debug for ServeConfig {
|
||||
{
|
||||
debug.field("git_seam", &self.git_seam.is_some());
|
||||
}
|
||||
debug
|
||||
.field("channel_client", &self.channel_client.as_ref().is_some())
|
||||
.field("limits", &self.limits)
|
||||
.finish()
|
||||
debug.field("channel_client", &self.channel_client.as_ref().is_some());
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
debug.field("tunnel_listen_seam", &self.tunnel_listen_seam.is_some());
|
||||
}
|
||||
debug.field("limits", &self.limits).finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +1083,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* the `w3-tunnel-listen` seam shape (the
|
||||
/// unset-declines contract's tunnel extension): the seam starts
|
||||
/// unset (the listen rows decline — an egress-only door is
|
||||
/// legitimate config), sets independently of the fabric handle,
|
||||
/// and Debug-shapes without registry internals.
|
||||
#[cfg(feature = "tunnel")]
|
||||
#[test]
|
||||
fn serve_config_listen_seam_unset_declines_and_sets_independently() {
|
||||
let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
|
||||
|
||||
assert!(serve.tunnel_listen_seam.is_none());
|
||||
assert!(!serve.tunnel_listen_seam_configured());
|
||||
assert!(!serve.tunnel_configured());
|
||||
|
||||
let debug = format!("{serve:?}");
|
||||
assert!(debug.contains("tunnel_listen_seam: false"));
|
||||
|
||||
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
|
||||
serve.tunnel_listen_seam = Some(super::super::TunnelListenSeam::new(Default::default()));
|
||||
assert!(serve.tunnel_listen_seam_configured());
|
||||
|
||||
let debug = format!("{serve:?}");
|
||||
assert!(debug.contains("tunnel_listen_seam: true"));
|
||||
let seam_debug = format!("{:?}", serve.tunnel_listen_seam.as_ref().expect("holds"));
|
||||
assert!(
|
||||
seam_debug.contains("<shared OperationRegistry>"),
|
||||
"the seam Debug shapes the registry without internals: {seam_debug}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_config_key_source_stays_fail_closed_through_the_config() {
|
||||
let dial = DialConfig::new("fleet.example:22", key_source(), Arc::new(RejectAll));
|
||||
|
||||
+30
-12
@@ -104,15 +104,19 @@ pub enum KeySourceError {
|
||||
Decode(String),
|
||||
}
|
||||
|
||||
/// The adapter (server-direction) error surface — the two failure
|
||||
/// classes the mount point can reach, deliberately closed:
|
||||
/// The adapter (server-direction) error surface — the failure classes
|
||||
/// the mount point can reach, deliberately closed:
|
||||
///
|
||||
/// - **Construction** — the [`SshAdapter::new`](crate::SshAdapter::new)
|
||||
/// host-key load failed: the [`KeySourceError`] variant is preserved
|
||||
/// verbatim (locked / absent / decryption / unknown-version / decode
|
||||
/// each stays itself) and **no adapter is constructed** (ADR-005
|
||||
/// decision 6 — never a half-configured adapter, never a keyless
|
||||
/// server that fails later).
|
||||
/// setup failed *before any session exists*: a failed host-key load
|
||||
/// (the [`KeySourceError`] variant is preserved verbatim — locked /
|
||||
/// absent / decryption / unknown-version / decode each stays itself)
|
||||
/// or a refused `-R` listen-leg registration (`ListenRegistration`,
|
||||
/// `w3-tunnel-listen` — a fabric that could mint listens the door
|
||||
/// cannot serve is worse than a half-configured adapter).
|
||||
/// **No adapter is constructed** (ADR-005 decision 6 — never a
|
||||
/// half-configured adapter, never a keyless server that fails
|
||||
/// later).
|
||||
/// - **Wire** — the russh session stack's own error, mapped
|
||||
/// transparently (`From<russh::Error>`, the wave-1 error-model's
|
||||
/// reserved `From` requirement of the per-connection
|
||||
@@ -122,7 +126,7 @@ pub enum KeySourceError {
|
||||
/// dispatch's SSH-shaped responses, never this taxonomy.
|
||||
///
|
||||
/// There is no catch-all variant: everything the crate's server
|
||||
/// direction can fail is one of these two classes or a decline.
|
||||
/// direction can fail is one of these classes or a decline.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AdapterError {
|
||||
/// The host-key load at adapter construction refused — fail-closed
|
||||
@@ -131,6 +135,14 @@ pub enum AdapterError {
|
||||
#[error("ssh server construction refused: {0}")]
|
||||
Construction(#[from] KeySourceError),
|
||||
|
||||
/// The `-R` listen leg's door-side openable registration refused
|
||||
/// at construction (`w3-tunnel-listen`): fail-closed, never a
|
||||
/// half-configured adapter — a construction where the fabric could
|
||||
/// mint listens the door cannot serve is worse than no adapter.
|
||||
/// The string carries the registration failure detail.
|
||||
#[error("ssh server construction refused (listen registration): {0}")]
|
||||
ListenRegistration(String),
|
||||
|
||||
/// A mounted russh session errored (transport / KEX / IO class).
|
||||
#[error("ssh session error: {0}")]
|
||||
Wire(#[from] russh::Error),
|
||||
@@ -209,21 +221,27 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(AdapterError::Construction)
|
||||
.collect();
|
||||
all.push(AdapterError::ListenRegistration("duplicate".into()));
|
||||
all.push(AdapterError::Wire(russh::Error::UnknownKey));
|
||||
all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_error_closed_taxonomy_two_classes() {
|
||||
let exhaustive: fn(&AdapterError) -> &'static str = |e| match e {
|
||||
AdapterError::Construction(_) => "construction",
|
||||
AdapterError::Wire(_) => "wire",
|
||||
};
|
||||
let exhaustive: fn(&AdapterError) -> &'static str = match_fn();
|
||||
for v in all_adapter_variants() {
|
||||
assert!(!exhaustive(&v).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn match_fn() -> fn(&AdapterError) -> &'static str {
|
||||
|e| match e {
|
||||
AdapterError::Construction(_) => "construction",
|
||||
AdapterError::ListenRegistration(_) => "construction",
|
||||
AdapterError::Wire(_) => "wire",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_preserves_the_key_source_variant() {
|
||||
for source in all_variants() {
|
||||
|
||||
+350
-79
@@ -56,8 +56,12 @@
|
||||
//! only after the fabric open succeeds, `ConnectFailed` on failure
|
||||
//! ([`crate::dispatch::connect_failed_open_decline`], the wave-2
|
||||
//! seam stub's reason surviving as the real decline).
|
||||
//! `forwarded-tcpip` stays the stub until `w3-tunnel-listen` lands
|
||||
//! the `-R` data leg's flow (the mapping row's listen-flow shape).
|
||||
//! An **inbound** `forwarded-tcpip` open — arriving *from* the vanilla
|
||||
//! client — declines with the same explicit `ConnectFailed` as the
|
||||
//! unvanilla-arrival class (`w3-tunnel-listen`: the real `-R` data
|
||||
//! leg is the door's own server-initiated opens, driven by the
|
||||
//! [`crate::tunnel_map`] arrival pump; a vanilla client never
|
||||
//! initiates the type, OpenSSH parity).
|
||||
//! The bare
|
||||
//! `auth-agent@openssh.com` channel *type*
|
||||
//! (`forwarded-streamlocal@openssh.com` likewise) has no russh handler
|
||||
@@ -154,7 +158,14 @@
|
||||
//! the open *before* any channel is owned — nothing is served yet,
|
||||
//! so nothing spawns. The route-1 bridge (`w3-tunnel-direct`) moved
|
||||
//! the direct rows' accept past the fabric open: the accept and the
|
||||
//! pump ride the spawned task, per the same constraint.
|
||||
//! pump ride the spawned task, per the same constraint. The `-R`
|
||||
//! listen rows' family-ward dial is the one **inline await** on the
|
||||
//! loop (`w3-tunnel-listen`'s recorded decision: the global request's
|
||||
//! reply + the `&mut u32` bound-port out-param are the hook's own —
|
||||
//! no spawned-task answer exists at this pin — and the fabric's
|
||||
//! answer rides the fabric leg's connections, not this session's
|
||||
//! routing; bounded by `LISTEN_DIAL_TIMEOUT`), while everything under
|
||||
//! the reply — the arrivals, the pumps — stays spawned.
|
||||
//!
|
||||
//! Request-level declines: `subsystem_request` declines every name —
|
||||
//! `sftp` is the OQ-13 placeholder (the feature exists, the route
|
||||
@@ -164,9 +175,11 @@
|
||||
//! declines unconditionally (OQ-09 — no allowlist ships: wire env is
|
||||
//! not family config). `x11_request` and `agent_request` decline
|
||||
//! (ADR-003 §5). The global listen requests (`tcpip_forward`
|
||||
//! / `streamlocal_forward` / the cancel forms) decline — they are
|
||||
//! `tunnel` rows whose serving flow is wave-3 material, so featured
|
||||
//! builds decline them too this wave.
|
||||
//! / `streamlocal_forward` + their cancel forms) — the `-R` rows
|
||||
//! (`w3-tunnel-listen`): the real flow under `tunnel` with the listen
|
||||
//! seam configured (the family-ward `TunnelListener::open` dial, the
|
||||
//! arrivals' arrival pump through the shared router); every decline
|
||||
//! class keeps a global-request-level reason (see the hooks).
|
||||
//!
|
||||
//! ## Wire decline reasons (the wave-1 error-model reservation)
|
||||
//!
|
||||
@@ -179,7 +192,8 @@
|
||||
//! prohibited; the direct rows' cannot-bridge classes (the fabric
|
||||
//! entry unconfigured under the feature, the failed fabric open, an
|
||||
//! impossible wire target), the off-Unix `direct-streamlocal`
|
||||
//! decline, and the `forwarded-tcpip` stub → connect failed). These
|
||||
//! decline, and the unvanilla inbound `forwarded-tcpip` open →
|
||||
//! connect failed). These
|
||||
//! happen before the channel exists, so the SSH open-failure code is
|
||||
//! the honest, richest reason surface.
|
||||
//! - **Request-level declines** — [`Session::channel_failure`]: the
|
||||
@@ -233,7 +247,14 @@
|
||||
//! spawned task on a fabric-open failure (the admitted-but-rejected
|
||||
//! shape: ADR-006 decision 1 rejects the open, so no `channel_close`
|
||||
//! can ever fire for it; the release mechanism is the shared
|
||||
//! [`ChannelCap`] `Arc`, recorded in that module). The order of the
|
||||
//! [`ChannelCap`] `Arc`, recorded in that module). The `-R` leg adds
|
||||
//! its two budget surfaces (`w3-tunnel-listen`, recorded in
|
||||
//! `channel_cap.rs`): the arrivals admit into the owning route's
|
||||
//! count inside the spawned arrival tasks (a beyond-cap arrival never
|
||||
//! opens an SSH channel — teardown-at-birth), and the listen count is
|
||||
//! its own dedicated counter against the same ceiling (the
|
||||
//! listener/channels mix stays unconstrained; both stay bounded). The
|
||||
//! order of the
|
||||
//! gates: the feature gate first (an unfeatured build declines
|
||||
//! everything before the cap matters), then the pure-decline classes
|
||||
//! (the tunnel rows' unset-handle/unix-gate/identity/invalid-target
|
||||
@@ -277,7 +298,7 @@ use crate::fingerprint::fingerprint;
|
||||
#[cfg(feature = "git")]
|
||||
use crate::git_map;
|
||||
use crate::observe;
|
||||
#[cfg(any(feature = "git", feature = "session"))]
|
||||
#[cfg(any(feature = "git", feature = "session", feature = "tunnel"))]
|
||||
use crate::serve;
|
||||
#[cfg(feature = "session")]
|
||||
use crate::tty_map;
|
||||
@@ -298,14 +319,30 @@ use alktty::negotiation::NegotiateRequest;
|
||||
/// [`Self::resolved_identity`] — the wave-3 seam reads it).
|
||||
pub struct SessionHandler {
|
||||
serve: Arc<ServeConfig>,
|
||||
/// The in-crate concurrent-channel cap (`ChannelCap`), shared as
|
||||
/// an `Arc` since `w3-tunnel-direct`: the route-1 bridge's spawned
|
||||
/// task releases the count on a fabric-open failure (the
|
||||
/// The in-crate concurrent-channel cap (`w2-bounded-resources`),
|
||||
/// shared as an `Arc` since `w3-tunnel-direct`: the route-1 bridge's
|
||||
/// spawned task releases the count on a fabric-open failure (the
|
||||
/// admitted-but-rejected shape — no `channel_close` can ever fire
|
||||
/// for it; see `channel_cap.rs`'s release-sites record). Admits
|
||||
/// stay packet-loop-only (every `admit` call site is an open
|
||||
/// hook); the mutex makes the bridge-task releases sound.
|
||||
/// for it; see `channel_cap.rs`'s release-sites record). Admissions
|
||||
/// have two sites since `w3-tunnel-listen` — the packet loop's
|
||||
/// open hooks and the `-R` arrival tasks — and releases three; the
|
||||
/// mutex makes them sound (the reasoning, recorded in
|
||||
/// `channel_cap.rs`).
|
||||
channel_cap: Arc<ChannelCap>,
|
||||
/// The `-R` listen leg's per-session bookkeeping
|
||||
/// (`w3-tunnel-listen`): the minted-listen map (token → pending
|
||||
/// listen; written only by the packet loop's hooks — the listen
|
||||
/// global requests mints, the cancel forms + this handler's
|
||||
/// teardown remove, single-writer/no-lock like the `channels`
|
||||
/// store) and the listen count's dedicated
|
||||
/// `max_concurrent_channels`-ceiling counter (a listen is at least
|
||||
/// one future channel's potential; its own counter keeps the
|
||||
/// listener/channels mix unconstrained while both stay bounded —
|
||||
/// no unbounded listener map).
|
||||
#[cfg(feature = "tunnel")]
|
||||
listeners: HashMap<u32, tunnel_map::PendingListen>,
|
||||
#[cfg(feature = "tunnel")]
|
||||
listener_cap: tunnel_map::ListenerCap,
|
||||
resolved_identity: Option<Identity>,
|
||||
/// The store-and-take handoff (`w3-git-mapping`): confirmed
|
||||
/// session channels held from the open until their row takes or
|
||||
@@ -334,14 +371,28 @@ pub struct SessionHandler {
|
||||
teardown: tty_map::Teardown,
|
||||
}
|
||||
|
||||
#[cfg(feature = "session")]
|
||||
impl Drop for SessionHandler {
|
||||
/// Fires the session rows' teardown signal (`w3-tty-session`):
|
||||
/// every spawned serving task of this connection watches it and
|
||||
/// cancels its alktty session (the kill-on-drop cleanup) instead
|
||||
/// of outliving the connection.
|
||||
/// of outliving the connection. The `tunnel` build adds the `-R`
|
||||
/// leg's teardown (`w3-tunnel-listen`): the session's pending
|
||||
/// listens' tokens leave the shared router (a fabric arrival after
|
||||
/// the session is gone is the correlation-miss class —
|
||||
/// teardown-at-birth) and the listener objects drop here (the
|
||||
/// fabric-side reap). Any in-flight arrival already past the
|
||||
/// route lookup fails its dead-handle open and releases its
|
||||
/// admission — the never-established path.
|
||||
fn drop(&mut self) {
|
||||
#[cfg(feature = "session")]
|
||||
self.teardown.fire();
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
let tokens: Vec<u32> = self.listeners.keys().copied().collect();
|
||||
if let Some(router) = &self.serve.listen_router {
|
||||
router.remove_tokens(&tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +413,10 @@ impl SessionHandler {
|
||||
bridges: HashMap::new(),
|
||||
#[cfg(feature = "session")]
|
||||
teardown: tty_map::Teardown::new(),
|
||||
#[cfg(feature = "tunnel")]
|
||||
listeners: HashMap::new(),
|
||||
#[cfg(feature = "tunnel")]
|
||||
listener_cap: tunnel_map::ListenerCap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,19 +460,6 @@ impl SessionHandler {
|
||||
.admit(self.serve.limits.max_concurrent_channels)
|
||||
}
|
||||
|
||||
/// Whether the cap is already exhausted — the gate slot for the
|
||||
/// one row whose decline is still ordered *before* an accept
|
||||
/// decision without consuming an admission: the `forwarded-tcpip`
|
||||
/// stub (the `-R` data leg's flow is `w3-tunnel-listen` material —
|
||||
/// a declined stub opens nothing, so it must not consume a count;
|
||||
/// the direct rows moved to [`Self::admit_channel_open`]-at-the-
|
||||
/// accept flow when `w3-tunnel-direct` landed the route-1 bridge).
|
||||
fn channel_cap_exhausted(&self) -> bool {
|
||||
!self
|
||||
.channel_cap
|
||||
.has_room(self.serve.limits.max_concurrent_channels)
|
||||
}
|
||||
|
||||
/// The ADR-004 decision 1 bridge core, structured out of the
|
||||
/// [`Handler::auth_publickey`] hook so the contract tests can drive
|
||||
/// it directly: the hook's russh-owned argument shape
|
||||
@@ -690,12 +732,16 @@ impl russh::server::Handler for SessionHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `forwarded-tcpip` open gate (`tunnel` — the `-R` data
|
||||
/// leg): the same gate/cap/stub split as `direct-tcpip`. The
|
||||
/// harness's russh client cannot initiate this channel type (the
|
||||
/// server opens it toward the client), so its wire rows are
|
||||
/// wave-3 acceptor material; the gate itself is straight-line
|
||||
/// identical to the other tunnel rows.
|
||||
/// The `forwarded-tcpip` open gate (`tunnel`): an **inbound** open
|
||||
/// of this channel type — arriving *from* the vanilla client — is
|
||||
/// not the `-R` leg at all (the `-R` data leg is the door's own
|
||||
/// server-initiated opens via [`crate::tunnel_map`]'s arrival
|
||||
/// pump; a vanilla client never initiates `forwarded-tcpip`,
|
||||
/// OpenSSH parity). The gate + the unvanilla decline stay
|
||||
/// straight-line and inline: nothing serves, nothing was ever in
|
||||
/// flight, so no admission is touched, and the explicit
|
||||
/// `ConnectFailed` reject keeps the wave-2 wire shape (the event's
|
||||
/// class records it as the unvanilla-arrival decline).
|
||||
async fn channel_open_forwarded_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
@@ -712,12 +758,8 @@ impl russh::server::Handler for SessionHandler {
|
||||
reply.reject(dispatch::unfeatured_open_decline()).await;
|
||||
return Ok(());
|
||||
}
|
||||
if self.channel_cap_exhausted() {
|
||||
observe::decline_open(ROW, "cap-exceeded", channel.id().number());
|
||||
reply.reject(dispatch::cap_exceeded_open_decline()).await;
|
||||
return Ok(());
|
||||
}
|
||||
tunnel_row_stub(ROW, channel.id().number(), reply).await;
|
||||
observe::decline_open(ROW, "unvanilla-inbound", channel.id().number());
|
||||
reply.reject(dispatch::connect_failed_open_decline()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1098,57 +1140,297 @@ impl russh::server::Handler for SessionHandler {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The `tcpip-forward` listen request (`-R` ingress): a `tunnel`
|
||||
/// row whose serving flow is wave-3 material — the gate exists
|
||||
/// ([`Self::serves_tunnels`]) but even a featured build declines
|
||||
/// this wave (never advertise a listen that cannot serve). The
|
||||
/// bound-port `port = 0` picking rule rides the wave-3 flow.
|
||||
/// The `tcpip-forward` listen request (`-R` ingress, `w3-tunnel-
|
||||
/// listen`): the gate is [`Self::serves_tunnels`]; the real flow
|
||||
/// rides [`listen_request_dispatch`] (`tunnel` builds) — the
|
||||
/// family-ward `TunnelListener::open` dial, the bound-port out-param
|
||||
/// (`port = 0` → the fabric's actually-bound number, the sshd
|
||||
/// parity), the route + listener registration. Unfeatured builds
|
||||
/// decline with the global-request class; never a minted listen a
|
||||
/// door cannot serve.
|
||||
async fn tcpip_forward(
|
||||
&mut self,
|
||||
_address: &str,
|
||||
_port: &mut u32,
|
||||
address: &str,
|
||||
port: &mut u32,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
observe::decline_global_request("tcpip_forward", "unimplemented");
|
||||
Ok(false)
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
listen_request_dispatch(
|
||||
self,
|
||||
"tcpip_forward",
|
||||
address,
|
||||
alktunnels::params::Substrate::Tcp,
|
||||
Some(port),
|
||||
_session,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
{
|
||||
let _ = (address, port);
|
||||
observe::decline_global_request("tcpip_forward", "unfeatured");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing is ever registered this wave — the cancel form
|
||||
/// declines accordingly.
|
||||
/// The `cancel-tcpip-forward` cancel form (`-KR`, mux teardown,
|
||||
/// `ExitOnForwardFailure`): 1:1 into the family listener's teardown —
|
||||
/// the pending listen matched by (the wire address as the request
|
||||
/// carried it, the bound port), the route removed **before** any
|
||||
/// teardown (a racing arrival is already the correlation-miss
|
||||
/// class), the listener count released, the fabric channel's
|
||||
/// teardown spawned (`TunnelListener::close`). `false` for a
|
||||
/// (address, port) pair with no pending listen — the sshd parity
|
||||
/// (`cancel of a not-found forward`).
|
||||
async fn cancel_tcpip_forward(
|
||||
&mut self,
|
||||
_address: &str,
|
||||
_port: u32,
|
||||
address: &str,
|
||||
port: u32,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
observe::decline_global_request("cancel_tcpip_forward", "unimplemented");
|
||||
Ok(false)
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
cancel_listen_dispatch(self, "cancel_tcpip_forward", address, Some(port))
|
||||
}
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
{
|
||||
let _ = (address, port);
|
||||
observe::decline_global_request("cancel_tcpip_forward", "unfeatured");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `streamlocal-forward` listen request: the same decline as
|
||||
/// `tcpip-forward`, plus its runtime-Unix gate (OQ-11) riding the
|
||||
/// wave-3 serving flow it will gate.
|
||||
/// The `streamlocal-forward` listen request: the same listen flow
|
||||
/// as `tcpip_forward` over the unix substrate — with the OQ-11
|
||||
/// runtime-Unix gate riding the serving flow it serves (off-Unix
|
||||
/// it declines before any fabric call; universal compile, no
|
||||
/// per-platform feature fork). No port out-param (the unix listen
|
||||
/// carries no port semantics; the path echoes verbatim).
|
||||
async fn streamlocal_forward(
|
||||
&mut self,
|
||||
_socket_path: &str,
|
||||
socket_path: &str,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
observe::decline_global_request("streamlocal_forward", "unimplemented");
|
||||
Ok(false)
|
||||
#[cfg(feature = "tunnel")]
|
||||
{
|
||||
if !cfg!(unix) {
|
||||
observe::decline_global_request("streamlocal_forward", "unix-unavailable");
|
||||
return Ok(false);
|
||||
}
|
||||
listen_request_dispatch(
|
||||
self,
|
||||
"streamlocal_forward",
|
||||
socket_path,
|
||||
alktunnels::params::Substrate::Unix,
|
||||
None,
|
||||
_session,
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
{
|
||||
let _ = socket_path;
|
||||
observe::decline_global_request("streamlocal_forward", "unfeatured");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing is ever registered this wave — the cancel form
|
||||
/// declines accordingly.
|
||||
/// The `cancel-streamlocal-forward` form: the tcpip cancel's shape
|
||||
/// over the unix substrate (path-match only; no port).
|
||||
async fn cancel_streamlocal_forward(
|
||||
&mut self,
|
||||
_socket_path: &str,
|
||||
socket_path: &str,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
observe::decline_global_request("cancel_streamlocal_forward", "unimplemented");
|
||||
Ok(false)
|
||||
#[cfg(all(feature = "tunnel", unix))]
|
||||
{
|
||||
cancel_listen_dispatch(self, "cancel_streamlocal_forward", socket_path, None)
|
||||
}
|
||||
#[cfg(all(feature = "tunnel", not(unix)))]
|
||||
{
|
||||
let _ = socket_path;
|
||||
observe::decline_global_request("cancel_streamlocal_forward", "unix-unavailable");
|
||||
Ok(false)
|
||||
}
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
{
|
||||
let _ = socket_path;
|
||||
observe::decline_global_request("cancel_streamlocal_forward", "unfeatured");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tcpip_forward` / `streamlocal_forward` global requests' real
|
||||
/// flow (`w3-tunnel-listen`; `mapping.md` §Global-request rows): the
|
||||
/// gate order is decline-first-alloc-never, each consumer checked
|
||||
/// before the next allocation is taken:
|
||||
///
|
||||
/// 1. **The `no-identity` guard** — unreachable on the wire (russh
|
||||
/// authorizes before requests serve) and fail-closed: never
|
||||
/// anonymous listens.
|
||||
/// 2. **The seam checks** — the fabric handle or the listen seam
|
||||
/// (`listen_router` absent: an egress-only door — the seam unset or
|
||||
/// the registration never ran) decline `seam-unconfigured` before
|
||||
/// any dial.
|
||||
/// 3. **The OQ-11 unix gate** — the streamlocal variant's off-Unix
|
||||
/// decline rides its hook (checked before this fn runs, so the
|
||||
/// unreachable-on-Unix-only row never here).
|
||||
/// 4. **The listener cap** — the listen count's dedicated
|
||||
/// `max_concurrent_channels`-ceiling admission; beyond it
|
||||
/// `cap-exceeded`.
|
||||
///
|
||||
/// Then the family-ward dial ([`crate::tunnel_map::open_listen`], the
|
||||
/// `resource = <the wire bind endpoint>` naming convention) — awaited
|
||||
/// inline (the recorded inline decision: the global request's reply +
|
||||
/// the `&mut u32` out-param are the hook's; the fabric answer rides
|
||||
/// the fabric leg, not this session's packet loop; the dial is
|
||||
/// bounded by `LISTEN_DIAL_TIMEOUT`, so the stall is bounded). The
|
||||
/// reply's `bound` port writes the out-param (`port = 0` → the
|
||||
/// actually-bound number, sshd parity); the route registers into the
|
||||
/// shared router (session-scope: the owning session's handle + its
|
||||
/// channel-cap `Arc`) and the pending listen into the session's
|
||||
/// packet-loop-only map. `Ok(true)`'s answer is the only success
|
||||
/// shape; every decline path answers the vanilla client's
|
||||
/// `RequestDenied` via `Ok(false)`.
|
||||
#[cfg(feature = "tunnel")]
|
||||
async fn listen_request_dispatch(
|
||||
handler: &mut SessionHandler,
|
||||
row: &'static str,
|
||||
wire_endpoint: &str,
|
||||
substrate: alktunnels::params::Substrate,
|
||||
port: Option<&mut u32>,
|
||||
session: &mut Session,
|
||||
) -> Result<bool, AdapterError> {
|
||||
if handler.resolved_identity().is_none() {
|
||||
observe::decline_global_request(row, "no-identity");
|
||||
return Ok(false);
|
||||
}
|
||||
let (Some(client), Some(router)) = (
|
||||
handler.serve.channel_client.as_ref(),
|
||||
handler.serve.listen_router.as_ref(),
|
||||
) else {
|
||||
observe::decline_global_request(row, "seam-unconfigured");
|
||||
return Ok(false);
|
||||
};
|
||||
if !handler
|
||||
.listener_cap
|
||||
.admit(handler.serve.limits.max_concurrent_channels)
|
||||
{
|
||||
observe::decline_global_request(row, "cap-exceeded");
|
||||
return Ok(false);
|
||||
}
|
||||
let minted = match tunnel_map::open_listen(
|
||||
client,
|
||||
tunnel_map::listen_resource(wire_endpoint, substrate, port.as_deref().copied()),
|
||||
substrate,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(minted) => minted,
|
||||
Err(_) => {
|
||||
handler.listener_cap.release();
|
||||
observe::decline_global_request(row, "listen-failed");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let bound = minted.bound;
|
||||
let echo = match (&substrate, &bound) {
|
||||
(
|
||||
alktunnels::params::Substrate::Tcp,
|
||||
alktunnels::params::SubstrateAddr::HostPort {
|
||||
port: bound_port, ..
|
||||
},
|
||||
) => tunnel_map::RouteEcho::Tcp {
|
||||
address: wire_endpoint.to_owned(),
|
||||
port: u32::from(*bound_port),
|
||||
},
|
||||
(alktunnels::params::Substrate::Unix, alktunnels::params::SubstrateAddr::Path { path }) => {
|
||||
tunnel_map::RouteEcho::Unix { path: path.clone() }
|
||||
}
|
||||
_ => {
|
||||
handler.listener_cap.release();
|
||||
observe::decline_global_request(row, "listen-failed");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
if let (Some(port), Some(bound_port)) = (port, tunnel_map::bound_port(&bound)) {
|
||||
*port = bound_port;
|
||||
}
|
||||
let token = minted.token;
|
||||
router.register(
|
||||
token,
|
||||
tunnel_map::ListenRoute {
|
||||
echo,
|
||||
substrate,
|
||||
handle: Arc::new(session.handle()),
|
||||
cap: Arc::clone(&handler.channel_cap),
|
||||
cap_value: handler.serve.limits.max_concurrent_channels,
|
||||
},
|
||||
);
|
||||
handler.listeners.insert(
|
||||
token,
|
||||
tunnel_map::PendingListen {
|
||||
wire_address: wire_endpoint.to_owned(),
|
||||
bound_port: tunnel_map::bound_port(&bound).unwrap_or(0),
|
||||
substrate,
|
||||
listener: minted.listener,
|
||||
},
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The `cancel_tcpip_forward` / `cancel_streamlocal_forward` global
|
||||
/// requests' real flow (`w3-tunnel-listen`): 1:1 into the family
|
||||
/// listener's teardown. Match rule (sshd parity): the wire endpoint as
|
||||
/// the original request carried it + the bound port for the tcp
|
||||
/// variants (the client sends the same normalized string it sent
|
||||
/// originally, and the *allocated* port for `port = 0` mints — the
|
||||
/// value the listen reply carried); the path alone for the unix
|
||||
/// variant. Order: the route leaves the shared router **before** the
|
||||
/// entry leaves the session map (so a racing arrival is already the
|
||||
/// correlation-miss class — no post-cancel half-serve), then the
|
||||
/// listener count releases, then the family teardown spawns
|
||||
/// (`TunnelListener::close` inside the spawn — a bounded local
|
||||
/// shutdown + entry reap, not a serving path the hook may await).
|
||||
/// `Ok(true)` iff a pending listen matched.
|
||||
#[cfg(feature = "tunnel")]
|
||||
fn cancel_listen_dispatch(
|
||||
handler: &mut SessionHandler,
|
||||
row: &'static str,
|
||||
wire_endpoint: &str,
|
||||
port: Option<u32>,
|
||||
) -> Result<bool, AdapterError> {
|
||||
if handler.resolved_identity().is_none() {
|
||||
observe::decline_global_request(row, "no-identity");
|
||||
return Ok(false);
|
||||
}
|
||||
let token = handler.listeners.iter().find_map(|(token, pending)| {
|
||||
let port_matched = match port {
|
||||
Some(port) => pending.bound_port == port,
|
||||
None => pending.substrate == alktunnels::params::Substrate::Unix,
|
||||
};
|
||||
(pending.wire_address == wire_endpoint && port_matched).then_some(*token)
|
||||
});
|
||||
let Some(token) = token else {
|
||||
observe::decline_global_request(row, "no-such-listen");
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(entry) = handler.listeners.remove(&token) else {
|
||||
observe::decline_global_request(row, "no-such-listen");
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(router) = &handler.serve.listen_router {
|
||||
router.cancel(&token);
|
||||
}
|
||||
handler.listener_cap.release();
|
||||
serve::spawn_serving_path(async move {
|
||||
entry.listener.close().await;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The request-level decline shape for an unfeatured/unimplemented
|
||||
/// capability: fail the request and leave the channel usable (POC-1
|
||||
/// §5 — session survives declines, no state poisoning). The decline
|
||||
@@ -1440,14 +1722,3 @@ async fn direct_open_dispatch(
|
||||
Arc::clone(&handler.channel_cap),
|
||||
);
|
||||
}
|
||||
|
||||
/// The `forwarded-tcpip` seam stub — the `-R` data leg's decline until
|
||||
/// `w3-tunnel-listen` lands the listen flow this leg rides (the
|
||||
/// harness's russh client cannot even initiate this channel type —
|
||||
/// the server opens it toward the client — so its wire rows are
|
||||
/// acceptor material). The open-level decline event is emitted here:
|
||||
/// the explicit `ConnectFailed` reject ADR-006 decision 1 pins.
|
||||
async fn tunnel_row_stub(row: &'static str, channel_id: u32, reply: ChannelOpenHandle) {
|
||||
observe::decline_open(row, "connect-failed", channel_id);
|
||||
reply.reject(dispatch::connect_failed_open_decline()).await;
|
||||
}
|
||||
@@ -47,6 +47,8 @@ pub use adapter::SshAdapter;
|
||||
pub use config::GitSeam;
|
||||
#[cfg(feature = "session")]
|
||||
pub use config::SessionSeam;
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub use config::TunnelListenSeam;
|
||||
pub use config::{DialConfig, Limits, ServeConfig};
|
||||
pub use error::{AdapterError, KeySourceError};
|
||||
pub use fingerprint::fingerprint;
|
||||
|
||||
+59
-3
@@ -55,7 +55,8 @@
|
||||
//!
|
||||
//! Decline events additionally carry exactly:
|
||||
//!
|
||||
//! - `decline_class` — `"open"` | `"request"` | `"serving"` | `"auth"`:
|
||||
//! - `decline_class` — `"open"` | `"request"` | `"serving"` |
|
||||
//! `"arrival"` | `"auth"`:
|
||||
//! the two wire decline classes of `mapping.md` §Wire decline
|
||||
//! reasons, plus the serving-path class (this wave the seam stub's
|
||||
//! decline *is* the serving path; the wave-3 mappings emit the same
|
||||
@@ -93,8 +94,16 @@
|
||||
//! path guards — unreachable in practice, fail-closed; the tunnel
|
||||
//! rows' open-level `no-identity` guard rides the same value —
|
||||
//! reachable never, on the wire, for the same reason),
|
||||
//! `"unimplemented"` (the subsystem rows — OQ-13 — and the wave-3
|
||||
//! listen forms), `"declined-by-design"` (env, OQ-09), and on the
|
||||
//! `"unimplemented"` (the subsystem rows — OQ-13),
|
||||
//! `"declined-by-design"` (env, OQ-09),
|
||||
//! `"correlation-miss"` / `"cap-exceeded"` / `"accept-refused"` /
|
||||
//! `"unvanilla-inbound"` (`w3-tunnel-listen`: the `-R` leg's arrival
|
||||
//! declines — the class whose events carry the minted `listen` token
|
||||
//! instead of a channel id — and the unvanilla inbound
|
||||
//! `forwarded-tcpip` open),
|
||||
//! `"listen-failed"` / `"no-such-listen"` (`w3-tunnel-listen`: the
|
||||
//! listen dial's failed/bounded outcome and the cancel forms'
|
||||
//! not-found pair) — the global-request and arrival rows, and on the
|
||||
//! auth class `"method-unimplemented"` / `"cert-unsupported"` /
|
||||
//! `"non-ed25519"` / `"fingerprint-unresolved"`.
|
||||
//! - `exec_route` — only on `exec_request`-row events: the dispatch
|
||||
@@ -225,6 +234,28 @@ pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// A decline on the `-R` leg's **arrival** surface (`w3-tunnel-
|
||||
/// listen`): a fabric inbound forwarded open that cannot become an
|
||||
/// SSH channel — `correlation-miss` (no route for the minted token —
|
||||
/// teardown-at-birth), `cap-exceeded` (the per-arrival admission), or
|
||||
/// `accept-refused` (the vanilla client rejected the SSH open — the
|
||||
/// count released, never a half-open channel). No SSH channel id
|
||||
/// exists yet at this surface (that is the point of the declines), so
|
||||
/// the minted `listen` token — the correlation key itself — rides
|
||||
/// instead; the token is bookkeeping metadata (the producer's minted
|
||||
/// correlation number), never secret material.
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub(crate) fn decline_arrival(row: &'static str, reason: &'static str, listen: u32) {
|
||||
tracing::debug!(
|
||||
feature_build = FEATURE_BUILD,
|
||||
row,
|
||||
listen,
|
||||
decline_class = "arrival",
|
||||
reason,
|
||||
"decline: forwarded arrival rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// A serving-path decline: a routed row whose serving task answers
|
||||
/// failure-then-close rather than serving. This wave that is the seam
|
||||
/// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3
|
||||
@@ -467,6 +498,31 @@ mod tests {
|
||||
assert_eq!(field(&fields, "error"), "Probe");
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`)
|
||||
/// — an arrival decline carries the minted `listen` token
|
||||
/// instead of a channel id (none exists yet — that is the point
|
||||
/// of the declines; the token is bookkeeping metadata, never
|
||||
/// secret material).
|
||||
#[cfg(feature = "tunnel")]
|
||||
#[test]
|
||||
fn decline_arrival_carries_the_listen_token_not_a_channel_id() {
|
||||
let fields = capture(|| decline_arrival("forwarded-tcpip", "correlation-miss", 9182));
|
||||
assert_eq!(field(&fields, "feature_build"), FEATURE_BUILD);
|
||||
assert_eq!(field(&fields, "row"), "forwarded-tcpip");
|
||||
assert_eq!(field(&fields, "listen"), "9182");
|
||||
assert_eq!(field(&fields, "decline_class"), "arrival");
|
||||
assert_eq!(field(&fields, "reason"), "correlation-miss");
|
||||
assert_eq!(
|
||||
field(&fields, "message"),
|
||||
"decline: forwarded arrival rejected"
|
||||
);
|
||||
let names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
|
||||
assert!(
|
||||
!names.iter().any(|n| n == "channel_id"),
|
||||
"an arrival decline carries the listen token, never a channel id"
|
||||
);
|
||||
}
|
||||
|
||||
/// The feature-build string: every enabled feature appears; a
|
||||
/// featureless build reads `none`. The expected half is derived
|
||||
/// from `cfg!` independently of the crate's match — divergence
|
||||
|
||||
+512
-38
@@ -30,6 +30,129 @@
|
||||
//! observes a clean, channel-scoped open refusal (POC-1 §5 posture)
|
||||
//! — vanilla clients retry-observe a down forward instead of a hang.
|
||||
//!
|
||||
//! ## The `-R` listen flow (`w3-tunnel-listen` — the fabric listens,
|
||||
//! the client dials)
|
||||
//!
|
||||
//! The server direction of the route-1 bridge (`mapping.md`
|
||||
//! §Channel-type rows, the `forwarded-tcpip` row + §Global-request
|
||||
//! rows). Per the family door semantics: the door owns nothing local
|
||||
//! — **the fabric listens**; per arrival the data flows back toward
|
||||
//! the vanilla SSH client, which dials its own target. Three moving
|
||||
//! parts, all decided against alktunnels 0.4.1's actual surfaces with
|
||||
//! POC-3 §C.4 as the template:
|
||||
//!
|
||||
//! 1. **The listen dial** (`tcpip_forward` / `streamlocal_forward`):
|
||||
//! the door maps the vanilla listen request family-ward as
|
||||
//! `TunnelParams { resource: <the wire bind endpoint string>,
|
||||
//! substrate: Tcp/Unix }` — the **resource-naming convention
|
||||
//! recorded** (the one open surface this flow's composition had):
|
||||
//! ADR-001 keeps the wire field a *resource identifier*, and a
|
||||
//! fabric serving vanilla `-R` opt into dynamic-address binding by
|
||||
//! its own establisher/bind-closure treating the mapped string as
|
||||
//! its bind endpoint (`bindfirst_establisher` +
|
||||
//! `register_openable_with_establisher`, the alkcall ADR-049
|
||||
//! establisher shape — who may bind what is the fabric's ACL +
|
||||
//! its bind closure, assembly policy at the family's provider,
|
||||
//! never the door's). The dial is the published consumer surface —
|
||||
//! `TunnelListener::open(client, params)` on the held
|
||||
//! `ChannelClient` — bounded by [`LISTEN_DIAL_TIMEOUT`]; the
|
||||
//! reply's `bound` + minted `listen` token (the alkcall ADR-049
|
||||
//! amendment-3 establisher fields) are read through
|
||||
//! `parse-listen-reply`-class accessors; **a reply without both is
|
||||
//! a declined listen with the fabric listener torn down
|
||||
//! immediately** (never a minted-but-uncorrelatable listen — a
|
||||
//! declined listen never left a half state).
|
||||
//! 2. **The door-side arrival surface** — the composition decision
|
||||
//! (the task's second `w3-seam-config` course correction): the
|
||||
//! fabric dials `tunnel/forwarded` *toward the door* per alktunnels'
|
||||
//! bind-first dispatch (`make_bindfirst_accept_handler` +
|
||||
//! `ForwardedOpener` — the producer calls the op on the channel-0
|
||||
//! call plane toward the listen opener, and the door reply carries
|
||||
//! the door-allocated channel id the producer adopts; alkcall's
|
||||
//! odd/even split keeps the two open directions collision-free).
|
||||
//! Serving that op needs the door's channel-0 serving loop — an
|
||||
//! alkcall `ChannelClient` built with
|
||||
//! `from_connection_with_serving`, whose registry the assembly owns
|
||||
//! — so the serve-config carries [`crate::config::TunnelListenSeam`]
|
||||
//! (the same `Arc<OperationRegistry>` the assembly wired); alkssh
|
||||
//! registers the forwarded openable itself in
|
||||
//! [`install_forwarded_surface`] (called from
|
||||
//! [`crate::SshAdapter::new`]; the registration failure is a failed
|
||||
//! construction): a `ChannelCore` over a clone of the held
|
||||
//! client's manager + alktunnels' `tunnel_forwarded_spec()` +
|
||||
//! `make_forwarded_op_handler` over the crate's arrival pump, the
|
||||
//! wire-level ACL (`tunnel:forwarded` scope) and the per-connection
|
||||
//! channel budget enforced inside alkcall's dispatch.
|
||||
//! 3. **The arrival pump** — the `forwarded-tcpip` data leg: the
|
||||
//! registered [`ForwardedHandler`]-closure correlates the
|
||||
//! arrival's `listen` token through [`ListenRouter`] (the route was
|
||||
//! registered at listen acceptance with the owning session's
|
||||
//! handle + cap); a miss is **teardown-at-birth** (drop the fabric
|
||||
//! half, never a half-open SSH channel). On a hit, per vanilla `-R`
|
||||
//! convention: the door opens a `forwarded-tcpip` channel *toward
|
||||
//! the client* echoing **the listen request's own args** — the
|
||||
//! connected address as the wire `tcpip_forward` request carried it
|
||||
//! verbatim (the OpenSSH client matches that exact string; for
|
||||
//! `port = 0` mints the *actually bound* port from the fabric's
|
||||
//! `bound` reply — the sshd parity recorded in
|
||||
//! [`ListenRoute`]) — with the `TunnelForwardedParams.peer` arrival
|
||||
//! mapped to the originator fields (`HostPort` → address/port;
|
||||
//! `Path` → path + port 0 — the degenerate but typed mapping,
|
||||
//! unreachable for a TCP-minted listen). The accepted-or-rejected
|
||||
//! decision is the in-crate cap (the wave-2 gate's booked acceptor
|
||||
//! vocabulary — the per-arrival admission; a client-side rejection
|
||||
//! or a dead session releases the count, the established channel's
|
||||
//! release rides [`crate::handler`]'s `channel_close` like every
|
||||
//! established channel). Accept → the fabric `BiStream` pumped
|
||||
//! against the SSH channel ([`alkcall::channels::pump::pump_bidi`]).
|
||||
//! Unix-substrate arrivals (a `streamlocal_forward`-minted
|
||||
//! route) open `forwarded-streamlocal@openssh.com` with the listen
|
||||
//! path echoed (russh's server `Handle` carries the channel-open at
|
||||
//! this pin, and the vanilla client's `-R` unix listener is the
|
||||
//! consumer of that leg).
|
||||
//!
|
||||
//! **The inline listen dial (recorded decision):** the
|
||||
//! spawn-and-return rule keeps *serving paths* out of the packet
|
||||
//! loop — but the `tcpip_forward` global request's reply is bound to
|
||||
//! the hook itself (russh answers the want-reply from the hook's
|
||||
//! return + the `&mut u32` out-param; no handle surface responds to a
|
||||
//! global request from a spawned task at this pin), so the listen
|
||||
//! dial awaits inline. This cannot deadlock against the peer's
|
||||
//! follow-up bytes the way an inline serving path can (the vanilla
|
||||
//! client waits for the global reply before proceeding; the fabric's
|
||||
//! answer rides the fabric leg's connections, not this session's
|
||||
//! packet loop), and its stall is bounded: other channels of the
|
||||
//! connection pause only for [`LISTEN_DIAL_TIMEOUT`], and the
|
||||
//! fabric-dial failure answers a denied listen. All serving below the
|
||||
//! reply (none — the listen returns) and all arrival paths stay
|
||||
//! spawned.
|
||||
//!
|
||||
//! ## Bounded bookkeeping (AGENTS.md convention 9)
|
||||
//!
|
||||
//! - **The per-session listener map** (the session-handler's
|
||||
//! `listeners` — packet-loop-only writer, like the `channels`/
|
||||
//! `ptys` stores) maps the minted token → the pending listen (wire
|
||||
//! args, bound port, the [`alktunnels::consumer::TunnelListener`]
|
||||
//! whose `close`/`Drop` perform the fabric teardown). Its bound:
|
||||
//! the listen count shares the `Limits::max_concurrent_channels`
|
||||
//! *ceiling* in a dedicated counter — a listen is at least one
|
||||
//! future channel's potential, sessions minting unbounded listeners
|
||||
//! would mint an unbounded fabric-channel map on the shared leg.
|
||||
//! - **The route map** ([`ListenRouter`], shared with the registered
|
||||
//! arrival pump across every session riding one fabric leg) is
|
||||
//! bounded by construction: entries are minted exactly at accepted
|
||||
//! listens (listener-cap gated), removed 1:1 at the cancel forms
|
||||
//! and at the owning session's teardown (the handler's `Drop`
|
||||
//! removes its tokens — a stale route to a dead handle only ever
|
||||
//! costs one failed open that returns teardown-at-birth, never a
|
||||
//! half-open SSH channel).
|
||||
//! - **A cancelled / torn-down listen never half-serves**: the cancel
|
||||
//! path removes the route before any teardown (a racing arrival
|
||||
//! misses → teardown-at-birth), and `cancel_*` of an unknown
|
||||
//! (address, bound-port) pair answers `false` — the vanilla
|
||||
//! cancel contract (sshd parity: match by the raw wire string +
|
||||
//! the bound port).
|
||||
//!
|
||||
//! ## The runtime-Unix gate (OQ-11)
|
||||
//!
|
||||
//! `direct-streamlocal` compiles wherever `tunnel` compiles (universal
|
||||
@@ -38,7 +161,8 @@
|
||||
//! (never a partial bridge), on-Unix the real bridge. The gate lives
|
||||
//! in the dispatch hook ([`crate::handler`]'s `direct-streamlocal`
|
||||
//! row), before the cap admission — a platform-refused open consumes
|
||||
//! nothing.
|
||||
//! nothing. The gate rides the `streamlocal_forward` listen row the
|
||||
//! same way (`w3-tunnel-listen`).
|
||||
//!
|
||||
//! ## The identity ride (the visible-surface invariant's gate)
|
||||
//!
|
||||
@@ -51,53 +175,50 @@
|
||||
//! open that proceeds then rides the channels connection into the
|
||||
//! fabric, whose own `AccessControl::check(peer_identity)` gates the
|
||||
//! mapped `channels/tunnel/direct` open (alkcall CF-005/CF-006) —
|
||||
//! the composed-gate witness is the harness's identity rows.
|
||||
//! the composed-gate witness is the harness's identity rows. The
|
||||
//! `-R` leg composes the same gates on both of its directions: the
|
||||
//! listen dial rides the fabric leg's identity (`tunnel:open` scope —
|
||||
//! the assembly's dial-identity composition, unchanged), and the
|
||||
//! arrivals are gated by the door-side registry's ACL
|
||||
//! (`tunnel:forwarded` scope, per arrival) exactly when the assembly's
|
||||
//! fabric leg carries it.
|
||||
//!
|
||||
//! **`set_identity` mechanics (recorded — the task's decided point):**
|
||||
//! the alkhub `hub_leg.rs` one-time-set precedent
|
||||
//! (`channel0_conn.set_identity(identity)`) is a **serving-side** set —
|
||||
//! the hub resolves the peer identity and sets it on its accept-side
|
||||
//! channel-0 `Connection` inside the channel-0 install, before any
|
||||
//! dispatch. The client-side surface
|
||||
//! (`ChannelClient::take_call_connection()` → `CallConnection::
|
||||
//! connection()` → `Connection::set_identity`) is **rejected for this
|
||||
//! door**, verified against alkcall 0.10.1: the take is a one-shot
|
||||
//! single-slot detach ("taking it detaches the client's own calling
|
||||
//! surface" — every later `call_open_op`, and with it every
|
||||
//! `TunnelSession::open_direct`, errors "channel client closed"), and
|
||||
//! the slot is the **shared assembly handle**: `SshAdapter` hands one
|
||||
//! `Arc<ServeConfig>` to every mounted session, so the first session's
|
||||
//! take would detach the open surface for every later session of the
|
||||
//! whole serving direction — a strictly worse version of the relay
|
||||
//! note's "the detach is wrong for hubs" (three claimants). The
|
||||
//! identity that alkcall's protocol consults at the fabric's serving
|
||||
//! dispatch is the channels connection's dial identity (CF-005: set
|
||||
//! once at dial, propagated to channel 0 at `from_connection`) —
|
||||
//! crossing sides is not an alkcall wire concept. The composition —
|
||||
//! which channel identity the fabric leg dials with, against which the
|
||||
//! fabric's ACL gates alkssh's mapped opens — is therefore **assembly
|
||||
//! policy**: the assembly supplies both the injected
|
||||
//! `IdentityProvider` (the SSH-peer resolution) and the fabric leg
|
||||
//! (the handle and its dial identity); alkssh carries no deployment
|
||||
//! policy, and it structurally refuses anonymous serving (the
|
||||
//! `no-identity` guard) so no SSH-shaped exception to the
|
||||
//! visible-surface invariant exists. The upstream ask that would let
|
||||
//! alkssh do more (a non-detaching post-construction identity seam on
|
||||
//! `ChannelClient`, per-session fabric legs) is booked for the
|
||||
//! wave-3 gate.
|
||||
//! **`set_identity` mechanics (recorded — `w3-tunnel-direct`'s decided
|
||||
//! point, carried unchanged):** the alkhub `hub_leg.rs` one-time-set
|
||||
//! precedent (`channel0_conn.set_identity(identity)`) is a
|
||||
//! **serving-side** set — the identity the fabric leg dials with, and
|
||||
//! against which the fabric's ACL gates alkssh's mapped opens, is the
|
||||
//! assembly-supplied `ChannelClient`'s own dial identity (CF-005: set
|
||||
//! once at dial, propagated to channel 0 at `from_connection`); the
|
||||
//! door's `ChannelClient::take_call_connection` surface is rejected
|
||||
//! for this door (the detach would strand every later session), and
|
||||
//! the identity composition is assembly policy, structurally refused
|
||||
//! anonymous by the `no-identity` guard. The door-side arrival surface
|
||||
//! adds no new identity mechanics: alkssh registers the openable with
|
||||
//! an install-time anonymous `AuthContext` (transport-truthful fields
|
||||
//! are not alkssh's to know) and alkcall's per-call CF-005 overlay +
|
||||
//! the assembly's `ServingConfig` composition decides what identity
|
||||
//! the arriving forwarded call carries.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::channels::operations::ChannelCore;
|
||||
use alkcall::channels::pump::pump_bidi;
|
||||
use alktunnels::consumer::TunnelSession;
|
||||
use alktunnels::params::{Substrate, SubstrateAddr};
|
||||
use alkcall::core::auth::AuthContext;
|
||||
use alkcall::core::types::BiStream;
|
||||
use alktunnels::consumer::{TunnelListener, TunnelSession};
|
||||
use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams, TunnelParams};
|
||||
use alktunnels::producer::{ForwardedHandler, register_tunnel_forwarded_openable};
|
||||
use parking_lot::Mutex;
|
||||
use russh::Channel;
|
||||
use russh::server::ChannelOpenHandle;
|
||||
use russh::server::{ChannelOpenHandle, Handle};
|
||||
|
||||
use crate::channel_cap::ChannelCap;
|
||||
use crate::serve;
|
||||
use crate::{dispatch, observe};
|
||||
use crate::{config, dispatch, observe};
|
||||
|
||||
/// The SSH data channel type: russh's server-side channel over its own
|
||||
/// message type (the store-and-take handoff and the serving rows use
|
||||
@@ -159,3 +280,356 @@ pub(crate) fn spawn_direct_row(
|
||||
row, channel, reply, target, substrate, client, cap,
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The `-R` listen flow (w3-tunnel-listen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The wall-clock bound on the family-ward listen dial (`TunnelListener::
|
||||
/// open`): the fabric's establisher is already bound by alkcall's own
|
||||
/// establisher deadline (10 s, alkcall ADR-049 §2), so this matches it —
|
||||
/// a fabric that never answers a listen dial cannot hold the packet
|
||||
/// loop longer than a bounded dial.
|
||||
pub(crate) const LISTEN_DIAL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// One route in the listen-arrival bookkeeping — registered at listen
|
||||
/// acceptance, consulted per arrival. The echo fields are the vanilla
|
||||
/// `-R` convention decoded (sshd parity, recorded on [`ListenRouter`]):
|
||||
/// the connected address rides the wire `tcpip_forward` string
|
||||
/// **verbatim** (the OpenSSH client sent that exact string and matches
|
||||
/// the open's address field against it exactly), the port is the
|
||||
/// **actually bound** one (the fabric's `bound` reply — the sshd
|
||||
/// `get_local_port`-of-accepted-socket parity, which is what makes
|
||||
/// `port = 0` mints round-trip), and the unix path rides the
|
||||
/// `streamlocal_forward` request verbatim.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ListenRoute {
|
||||
/// The SSH args the arrivals' opens echo — keyed by the route's
|
||||
/// substrate (`Tcp`/`Udp` → the vanilla `-R` trio's first two
|
||||
/// fields; `Unix` → the `forwarded-streamlocal` path).
|
||||
pub(crate) echo: RouteEcho,
|
||||
pub(crate) substrate: Substrate,
|
||||
/// The owning session's packet-loop handle — the arrival's SSH
|
||||
/// channel opens through it; a session that died fails the open
|
||||
/// (the arrival's teardown-at-birth path).
|
||||
pub(crate) handle: Arc<Handle>,
|
||||
/// The owning session's in-crate concurrent-channel cap: the
|
||||
/// per-arrival admission (the acceptor vocabulary) + the release
|
||||
/// on the never-established paths.
|
||||
pub(crate) cap: Arc<ChannelCap>,
|
||||
pub(crate) cap_value: usize,
|
||||
}
|
||||
|
||||
/// The echoed SSH args of a route (see [`ListenRoute`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum RouteEcho {
|
||||
Tcp { address: String, port: u32 },
|
||||
Unix { path: String },
|
||||
}
|
||||
|
||||
/// The shared per-fabric-leg map of accepted listens
|
||||
/// (`token → route`), the arrival pump's correlation registry and the
|
||||
/// bounded bookkeeping's spine: entries are minted exactly at accepted
|
||||
/// listens (the per-session listener cap gated), taken at the cancel
|
||||
/// forms, and removed at the owning session's teardown (the handler's
|
||||
/// `Drop`). Arrivals that find no route are the correlation-miss
|
||||
/// class — teardown-at-birth, never a half-open SSH channel.
|
||||
///
|
||||
/// The map is behind a `parking_lot::Mutex` because arrivals arrive on
|
||||
/// alkcall serving tasks while the packet loop mints/removes — the
|
||||
/// held region is a HashMap op, never an await.
|
||||
pub(crate) struct ListenRouter {
|
||||
routes: Mutex<HashMap<u32, ListenRoute>>,
|
||||
}
|
||||
|
||||
impl ListenRouter {
|
||||
/// Minted-entry registration (the listen-acceptance step).
|
||||
pub(crate) fn register(&self, token: u32, route: ListenRoute) {
|
||||
self.routes.lock().insert(token, route);
|
||||
}
|
||||
|
||||
/// The per-arrival lookup — cloned out, never taken: one route
|
||||
/// serves unboundedly many arrivals; only the cancel forms and
|
||||
/// session teardown remove.
|
||||
pub(crate) fn route_for(&self, token: u32) -> Option<ListenRoute> {
|
||||
self.routes.lock().get(&token).cloned()
|
||||
}
|
||||
|
||||
/// The cancel-form removal (1:1 into the family listener's
|
||||
/// teardown): removes first, so a racing arrival already misses —
|
||||
/// teardown-at-birth, never a post-cancel half-serve.
|
||||
pub(crate) fn cancel(&self, token: &u32) -> bool {
|
||||
self.routes.lock().remove(token).is_some()
|
||||
}
|
||||
|
||||
/// The owning session's teardown (the handler's `Drop`): remove
|
||||
/// the session's tokens; any listener objects left drop with the
|
||||
/// handler (the fabric-side reap — see `PendingListen`).
|
||||
pub(crate) fn remove_tokens(&self, tokens: &[u32]) {
|
||||
let mut routes = self.routes.lock();
|
||||
for token in tokens {
|
||||
routes.remove(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListenRouter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
routes: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The door-side arrival surface's install step (called from
|
||||
/// [`crate::SshAdapter::new`] when the fabric leg + seam are both
|
||||
/// present): builds the router, registers alktunnels' forwarded
|
||||
/// openable into the seam's shared registry over a `ChannelCore`
|
||||
/// wrapping a clone of the held `ChannelClient`'s manager — the
|
||||
/// alkcall shape whose wrapper performs the door-side arrival's
|
||||
/// channel allocation, ACL (`tunnel:forwarded`, per call) and budget
|
||||
/// (alkcall's secure default per-identity policy, `256`, plus the
|
||||
/// `DEFAULT_MAX_CHANNELS` per-connection bound — alkssh composes no
|
||||
/// deployment policy; an assembly wanting a different per-identity
|
||||
/// cap shapes it through its own `ServingConfig`/`registry`
|
||||
/// composition upstream) — and spawns alktunnels' forward-op handler
|
||||
/// over the crate's arrival pump. The registration is one-per-op-id-
|
||||
/// per-registry (alkcall's rule): a conflicting registration is a
|
||||
/// failed construction (fail-closed — never a route to a handler the
|
||||
/// door does not own).
|
||||
///
|
||||
/// On success the router rides back into the serve-config (the
|
||||
/// session handlers read it) — `Ok` with the router installed, `Err`
|
||||
/// is the caller's constructor failure.
|
||||
pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Result<(), String> {
|
||||
let Some(seam) = serve.tunnel_listen_seam.as_mut() else {
|
||||
return Err("tunnel listen surface requested without a tunnel_listen_seam".to_string());
|
||||
};
|
||||
let Some(client) = serve.channel_client.as_ref() else {
|
||||
return Err("tunnel listen seam configured without a fabric leg".to_string());
|
||||
};
|
||||
let router = Arc::new(ListenRouter::default());
|
||||
let handler: ForwardedHandler = {
|
||||
let router = Arc::clone(&router);
|
||||
Arc::new(move |params: TunnelForwardedParams, bidi: BiStream| {
|
||||
tokio::spawn(pump_forwarded_arrival(Arc::clone(&router), params, bidi))
|
||||
})
|
||||
};
|
||||
let core = ChannelCore::new(
|
||||
client.manager().clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
register_tunnel_forwarded_openable(
|
||||
&core,
|
||||
&seam.registry,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
handler,
|
||||
)
|
||||
.map_err(|failure| format!("tunnel/forwarded openable registration: {failure}"))?;
|
||||
serve.listen_router = Some(Arc::clone(&router));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The registered arrival pump — the `forwarded-tcpip` data leg
|
||||
/// (mapping.md's row, "hand the channel duplex to the handler"): the
|
||||
/// alkcall openable registration hands every fabric arrival here with
|
||||
/// the parsed [`TunnelForwardedParams`] and the arrival's `BiStream`;
|
||||
/// the flow is the recorded shape:
|
||||
///
|
||||
/// 1. **Correlation** — the route for the arrival's minted token;
|
||||
/// a miss is teardown-at-birth (drop the fabric half; the alkcall
|
||||
/// wrapper's awaited join ends, the channel reaps).
|
||||
/// 2. **The in-crate cap admission** (bounded resources at the accept
|
||||
/// surface — the wave-2 gate's booked acceptor vocabulary): no
|
||||
/// room → the fabric half drops, the arrival never opens an SSH
|
||||
/// channel. The count releases here on every never-established
|
||||
/// path; an established one releases at its close (the packet
|
||||
/// loop's release site), the direct rows' disjoint-release shape.
|
||||
/// 3. **The SSH open toward the client** — the echoed listen args
|
||||
/// (`forwarded-tcpip` for the tcp/udp substrates with the originator
|
||||
/// fields mapped from `TunnelForwardedParams.peer`; `forwarded-
|
||||
/// streamlocal@openssh.com` with the echoed path for `Unix`) — per
|
||||
/// the spawn-and-return constraint this runs on this spawned task;
|
||||
/// a rejected/dead-open releases the count and drops the fabric
|
||||
/// half (never a half-open SSH channel).
|
||||
/// 4. **The pump** — the fabric `BiStream` ↔ the SSH channel duplex
|
||||
/// (the route-1 bridge's primitive, reversed).
|
||||
async fn pump_forwarded_arrival(
|
||||
router: Arc<ListenRouter>,
|
||||
params: TunnelForwardedParams,
|
||||
bidi: BiStream,
|
||||
) {
|
||||
let row: &'static str = match params.substrate {
|
||||
Substrate::Unix => "forwarded-streamlocal",
|
||||
Substrate::Tcp | Substrate::Udp => "forwarded-tcpip",
|
||||
};
|
||||
let Some(route) = router.route_for(params.listen) else {
|
||||
observe::decline_arrival(row, "correlation-miss", params.listen);
|
||||
drop(bidi);
|
||||
return;
|
||||
};
|
||||
if !route.cap.admit(route.cap_value) {
|
||||
observe::decline_arrival(row, "cap-exceeded", params.listen);
|
||||
drop(bidi);
|
||||
return;
|
||||
}
|
||||
let opened = match route.substrate {
|
||||
Substrate::Unix => match route.echo {
|
||||
RouteEcho::Unix { path } => route.handle.channel_open_forwarded_streamlocal(path).await,
|
||||
_ => {
|
||||
route.cap.release();
|
||||
observe::decline_arrival(row, "correlation-miss", params.listen);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Substrate::Tcp | Substrate::Udp => match (route.echo, ¶ms.peer) {
|
||||
(
|
||||
RouteEcho::Tcp { address, port },
|
||||
SubstrateAddr::HostPort {
|
||||
host,
|
||||
port: peer_port,
|
||||
},
|
||||
) => {
|
||||
route
|
||||
.handle
|
||||
.channel_open_forwarded_tcpip(
|
||||
address,
|
||||
port,
|
||||
host.clone(),
|
||||
u32::from(*peer_port),
|
||||
)
|
||||
.await
|
||||
}
|
||||
(RouteEcho::Tcp { address, port }, SubstrateAddr::Path { path }) => {
|
||||
// The degenerate-but-typed mapping (a path-shaped peer on
|
||||
// a tcp listen — unreachable from a well-behaved producer):
|
||||
// the path rides the originator string, port 0.
|
||||
route
|
||||
.handle
|
||||
.channel_open_forwarded_tcpip(address, port, path.clone(), 0)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
route.cap.release();
|
||||
observe::decline_arrival(row, "correlation-miss", params.listen);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
match opened {
|
||||
Ok(channel) => {
|
||||
let channel_number = channel.id().number();
|
||||
observe::serving_path_started(row, channel_number, None);
|
||||
let (ssh_read, ssh_write) = tokio::io::split(channel.into_stream());
|
||||
pump_bidi(bidi, ssh_read, ssh_write).await;
|
||||
observe::serving_path_ended(row, channel_number, None);
|
||||
}
|
||||
Err(_) => {
|
||||
route.cap.release();
|
||||
observe::decline_arrival(row, "accept-refused", params.listen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A minted listen — the door-side outcome of a `tcpip_forward` /
|
||||
/// `streamlocal_forward` request's family-ward dial (the published
|
||||
/// `TunnelListener::open`, bounded by [`LISTEN_DIAL_TIMEOUT`], with
|
||||
/// the reply's `bound` + `listen` establisher fields required: a
|
||||
/// producer that minted no correlation token or no bound report
|
||||
/// cannot serve — [`Result`]'s `Err` declines the SSH request before
|
||||
/// anything registers).
|
||||
pub(crate) struct MintedListen {
|
||||
pub(crate) listener: TunnelListener,
|
||||
pub(crate) token: u32,
|
||||
pub(crate) bound: SubstrateAddr,
|
||||
}
|
||||
|
||||
/// The listen dial (the mapping.md §Global-request rows' family-ward
|
||||
/// step): `TunnelParams { resource: <endpoint string>, substrate }`
|
||||
/// — the wire-named bind endpoint as the resource, the recorded
|
||||
/// door-side naming convention whose fabric-side interpretation is
|
||||
/// the assembly's establisher — then the reply projection. Called
|
||||
/// inline from the handler hook (see the recorded inline-decision in
|
||||
/// the module doc).
|
||||
pub(crate) async fn open_listen(
|
||||
client: &Arc<ChannelClient>,
|
||||
resource: String,
|
||||
substrate: Substrate,
|
||||
) -> Result<MintedListen, String> {
|
||||
let future = TunnelListener::open(
|
||||
client,
|
||||
TunnelParams {
|
||||
resource,
|
||||
substrate,
|
||||
},
|
||||
);
|
||||
let listener = match tokio::time::timeout(LISTEN_DIAL_TIMEOUT, future).await {
|
||||
Ok(Ok(listener)) => listener,
|
||||
Ok(Err(error)) => return Err(format!("listen dial failed: {error:?}")),
|
||||
Err(_) => return Err("listen dial timed out".to_string()),
|
||||
};
|
||||
let Some(token) = listener.listen() else {
|
||||
return Err("the fabric minted no listen token".to_string());
|
||||
};
|
||||
let Some(bound) = listener.bound().cloned() else {
|
||||
return Err("the fabric reported no bound endpoint".to_string());
|
||||
};
|
||||
Ok(MintedListen {
|
||||
listener,
|
||||
token,
|
||||
bound,
|
||||
})
|
||||
}
|
||||
|
||||
/// The bound port the listen request's out-param carries
|
||||
/// (the `&mut u32` sshd parity): the fabric's bound report's port.
|
||||
/// Unix listens have no port semantics — the tcp variants only.
|
||||
pub(crate) fn bound_port(bound: &SubstrateAddr) -> Option<u32> {
|
||||
match bound {
|
||||
SubstrateAddr::HostPort { port, .. } => Some(u32::from(*port)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The family-ward resource string for a listen request — the wire
|
||||
/// bind endpoint verbatim (`"localhost"` / `"0.0.0.0"` / explicit
|
||||
/// host: the OpenSSH client has already normalized the no-address case
|
||||
/// to `"localhost"` and `""` before this surface exists — sshd
|
||||
/// `channel_rfwd_bind_host` parity), with the requested port for the
|
||||
/// tcp substrates (`host:port`; port `0` stays `0` — the OS-picking is
|
||||
/// the fabric's bind step, whose bound report feeds the out-param).
|
||||
pub(crate) fn listen_resource(endpoint: &str, substrate: Substrate, port: Option<u32>) -> String {
|
||||
match substrate {
|
||||
Substrate::Unix => endpoint.to_owned(),
|
||||
_ => format!("{}:{}", endpoint, port.unwrap_or(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-session pending listen (the packet-loop-only store, like
|
||||
/// the `channels`/`ptys` stores): the minted token is the map's key;
|
||||
/// the entry carries what the cancel forms match on (the wire endpoint
|
||||
/// verbatim + the bound port — the sshd `c->path == host &&
|
||||
/// c->listening_port == port` parity) over the substrate (a unix
|
||||
/// entry's port is 0-carrying and never matched — the unix cancel
|
||||
/// forms match on the path alone) and the session-owned
|
||||
/// [`TunnelListener`] whose close/Drop performs the family teardown.
|
||||
pub(crate) struct PendingListen {
|
||||
pub(crate) wire_address: String,
|
||||
pub(crate) bound_port: u32,
|
||||
pub(crate) substrate: Substrate,
|
||||
pub(crate) listener: TunnelListener,
|
||||
}
|
||||
|
||||
/// The listen count's dedicated bound — the same mechanism as
|
||||
/// [`ChannelCap`], instantiated per session next to it, against the
|
||||
/// same `Limits::max_concurrent_channels` ceiling: listeners and
|
||||
/// channels stay independently bounded while their mix stays
|
||||
/// unconstrained (AGENTS.md convention 9 — no unbounded listener
|
||||
/// map). Admits are packet-loop-only (the listen hooks are the only
|
||||
/// mint site); releases fire at the cancel forms, the listen-dial
|
||||
/// failures (admitted-but-never-minted — the route 1 bridge's
|
||||
/// release-site shape), and nowhere else (a minted listener's release
|
||||
/// is... never — listeners hold until cancel or session teardown, so
|
||||
/// the session's count dies with the handler, per the channels
|
||||
/// cap's same posture).
|
||||
pub(crate) type ListenerCap = ChannelCap;
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w3-tunnel-listen
|
||||
name: Wave 3 — tunnel leg 2 (the -R listen flow + the forwarded-tcpip data leg)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w3-tunnel-direct]
|
||||
scope: moderate
|
||||
risk: high
|
||||
@@ -87,24 +87,24 @@ target):
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `tcpip_forward` / `streamlocal_forward` (+ cancel forms) carry
|
||||
- [x] `tcpip_forward` / `streamlocal_forward` (+ cancel forms) carry
|
||||
the real mapped flow under `tunnel` (+ the runtime-Unix gate
|
||||
where it binds); decline classes keep their wire shapes
|
||||
- [ ] The family-ward listen composition decided against alktunnels
|
||||
- [x] The family-ward listen composition decided against alktunnels
|
||||
0.4.1's actual surfaces and recorded; the door-side inbound-open
|
||||
mechanics closed (or a serve-config amendment landed + recorded;
|
||||
or stopped-and-booked for the gate — nothing silent)
|
||||
- [ ] The `forwarded-tcpip` data leg serves per the handler shape
|
||||
- [x] The `forwarded-tcpip` data leg serves per the handler shape
|
||||
(target-arg echo per pending listen, cap-gated acceptor, the
|
||||
pump, teardown-at-birth on correlation-miss)
|
||||
- [ ] The per-session listener bookkeeping bounded; the bound-port
|
||||
- [x] The per-session listener bookkeeping bounded; the bound-port
|
||||
out-param returns the actually-bound number
|
||||
- [ ] The per-build decline-matrix rows green through the harness with
|
||||
- [x] The per-build decline-matrix rows green through the harness with
|
||||
the client-side accept hook in place (the wave-2 gate's booked
|
||||
harness item discharged)
|
||||
- [ ] The real-tool smoke posture decided + recorded; the wave-2 stub
|
||||
- [x] The real-tool smoke posture decided + recorded; the wave-2 stub
|
||||
listen rows migrated
|
||||
- [ ] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2
|
||||
- [x] `cargo test` (+ per-feature + `--all-features` rows), clippy ×2
|
||||
(`-D warnings`), fmt, `cargo doc --no-deps` pass
|
||||
|
||||
## References
|
||||
@@ -132,4 +132,128 @@ target):
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled at implementation time.
|
||||
**Landed.** The `-R` leg is served for real: the listen flow
|
||||
(`tcpip_forward`/`streamlocal_forward` + cancel forms under `tunnel`)
|
||||
and the `forwarded-tcpip`/`forwarded-streamlocal` data leg, verified
|
||||
against the published alktunnels 0.4.1 / alkcall 0.10.1 surfaces
|
||||
through the harness and (tooling present) a real OpenSSH `ssh -R`
|
||||
smoke. 18 server-direction rows + 2 real-tool smokes green in
|
||||
`tests/tunnel_mapping.rs`.
|
||||
|
||||
- **The composition decisions (recorded in `src/tunnel_map.rs`'s
|
||||
module doc + `mapping.md` §Global-request rows):**
|
||||
- *Who dials `tunnel/forwarded`*: the fabric (not the door) —
|
||||
alktunnels' bind-first accept dispatcher
|
||||
(`make_bindfirst_accept_handler` + the `ForwardedOpener` closure)
|
||||
dials the op on the channel-0 call plane toward the listen opener
|
||||
per arrival; the door reply carries the door-allocated channel id
|
||||
the producer adopts (alkcall's odd/even split keeps the two open
|
||||
directions collision-free: the door allocates odd as the connect
|
||||
side, the fabric even as the accept side).
|
||||
- *The door-side inbound-open mechanics*: closed over the published
|
||||
surfaces — the `Arc<ChannelClient>` alone cannot carry them (a
|
||||
client serves inbound ops on its channel-0 loop only when built
|
||||
with `from_connection_with_serving` against an `OperationRegistry`
|
||||
the assembly owns; no post-construction way in) — so the
|
||||
serve-config's tunnel handle gained `tunnel_listen_seam`
|
||||
(`TunnelListenSeam`, the wave-3 seam-config's second
|
||||
course-correction): the same `Arc<OperationRegistry>` the assembly
|
||||
wired into the leg's `ServingConfig`. alkssh registers the
|
||||
forwarded openable itself (`register_tunnel_forwarded_openable`
|
||||
over a `ChannelCore` wrapping a clone of the held client's
|
||||
manager, alkcall's secure default per-identity policy) at
|
||||
`SshAdapter::new` — a registration failure is a failed
|
||||
construction (`AdapterError::ListenRegistration`), never a
|
||||
half-configured adapter. A scratch probe (published crates only,
|
||||
no `alktunnels/local`) proved the topology end to end before any
|
||||
of this landed: door-side registration → fabric bind-first
|
||||
producer → arrival → `ForwardedHandler` (params + `BiStream`) →
|
||||
pump round trip, with the door's own pending calls still resolving
|
||||
while serving is enabled.
|
||||
- *The resource-naming convention (the flow's one open surface)*:
|
||||
the vanilla wire bind endpoint rides family-ward as the resource
|
||||
string verbatim (`"localhost:9200"` / `"0.0.0.0:9200"` / the
|
||||
socket path) — the OpenSSH client normalizes the no-address case
|
||||
to `"localhost"` before sending (sshd `channel_rfwd_bind_host`
|
||||
parity), the door passes through, and the fabric's
|
||||
establisher/bind closure (assembly policy — the dynamic-address
|
||||
producer opt-in the door's convention fronts) treats it as the
|
||||
bind endpoint. ADR-001's "resource is not an address" discipline
|
||||
stays a producer-side naming rule; this is a recorded door-to-
|
||||
fabric convention, not a wire change.
|
||||
- **The `forwarded-tcpip` data leg** (the row the POC only exercised
|
||||
as refusal): per arrival → token correlation through the shared
|
||||
`ListenRouter` (one route serves unboundedly many arrivals;
|
||||
removed only at the cancel forms/session teardown) → the in-crate
|
||||
per-session channel cap admits at the accept surface — the acceptor
|
||||
vocabulary's first shape, the wave-2 gate's booked item now
|
||||
discharged (never-established arrival paths release inside the
|
||||
arrival pump; established closes release through the packet loop's
|
||||
`channel_close` as ever) → `Handle::channel_open_forwarded_tcpip`
|
||||
echoing the listen request's own args: the wire address string
|
||||
verbatim + the actually-bound port (sshd `c->path` +
|
||||
`get_local_port` parity — the OpenSSH client matches that exact
|
||||
string + port and dials its own target), the
|
||||
`TunnelForwardedParams.peer` arrival mapped to the originator
|
||||
fields (HostPort → address/port; Path → path + 0,
|
||||
degenerate-but-typed) → `pump_bidi(ssh, fabric)` (half-close
|
||||
semantics: EOF-shutdown in the completed direction; the vanilla
|
||||
client closes its half when its target socket dies — the row's
|
||||
stand-in — and the cap release follows through the normal close).
|
||||
Correlation-miss → teardown-at-birth (drop the fabric half; the
|
||||
open op's channel reaps — never a half-open SSH channel). Unix
|
||||
arrivals (`streamlocal_forward` mints) open
|
||||
`forwarded-streamlocal@openssh.com` with the path echoed (russh's
|
||||
server `Handle` carries that channel-open at this pin; the client
|
||||
hook exists) — the "no russh hook" concern in the wave-2 stub notes
|
||||
was about the door's *inbound* handlers of that type, not the
|
||||
server-initiated direction.
|
||||
- **Bounded bookkeeping** (convention 9): the per-session listener
|
||||
map is packet-loop-only (like the `channels`/`ptys` stores); its
|
||||
bound is a dedicated counter against the same
|
||||
`max_concurrent_channels` ceiling — a listen is at least one future
|
||||
channel's potential; the listener/channels mix stays unconstrained
|
||||
while both stay bounded (no unbound listener map). The route map is
|
||||
bounded by construction: entries minted exactly at accepted listens,
|
||||
removed 1:1 at the cancels and at the owning session's teardown (the
|
||||
handler's `Drop`); a route to a dead handle only ever costs one
|
||||
failed open → teardown-at-birth. `cancel` of an unknown
|
||||
(endpoint, bound-port) pair answers `false` (sshd
|
||||
cancel-of-unknown parity). Admissions have two sites and releases
|
||||
three since this task — the cap's recorded single-writer reasoning
|
||||
updated in `channel_cap.rs` (mutex-guarded, saturating, both).
|
||||
- **The real-tool smoke**: implemented and green here (tooling
|
||||
check-gated like the -L smoke's posture): a real `ssh -R
|
||||
127.0.0.1:PORT:HTTP` (OpenSSH client) through the TCP-mounted
|
||||
adapter → the fabric double with a real bind → arrival → the
|
||||
forwarded-tcpip open → the client dials its own endpoint → curled
|
||||
body parity. Never silently dropped.
|
||||
- **The decline matrix** per build, all through the harness with the
|
||||
client-side accept hook in place (the harness handler's
|
||||
`server_channel_open_forwarded_tcpip` /
|
||||
`server_channel_open_forwarded_streamlocal`, acceptor-driven — no
|
||||
acceptor → clean decline, the family's client-side contract): the
|
||||
wave-2 stub listen rows migrated (`tcpip_forward_decline_emits_*`
|
||||
split per build: `unfeatured` vs `seam-unconfigured`); the
|
||||
featured + configured rows serve/arrive/cancel (incl. the port-0
|
||||
mint resolving into the `&mut u32` out-param and the echo); the
|
||||
unset listen seam declines while the egress bridge still routes
|
||||
(the row's control leg); the listener cap and the per-arrival
|
||||
channel cap rows (refusal + release proof); the composed-identity
|
||||
rows (the fabric-scope gate on the listen dial, the door-side ACL
|
||||
gate on arrivals); the OQ-11 off-Unix rows; the correlation-miss
|
||||
row; the unvanilla inbound `forwarded-tcpip` open declined
|
||||
(event-level contract — russh's client has no arbitrary-type open
|
||||
to drive that row on the wire).
|
||||
- **`tests/common` grew**: the forwarding acceptor
|
||||
(`ForwardedAcceptor` + `ForwardedOpen` + `drive_with_acceptor`)
|
||||
and a minimal hand-rolled tracing subscriber (`trace_global` — the
|
||||
family posture: no `tracing-subscriber` dependency; opted in per
|
||||
row) as the leg-2 rows' failure-triage vehicle.
|
||||
- **Verification (all green)**: `cargo test` across default /
|
||||
`tunnel` / `git` / `session` / `sftp` / `tunnel,git` /
|
||||
`tunnel,session` / `git,session` / `tunnel,sftp` / `git,sftp` /
|
||||
`--all-features`; `cargo clippy --all-targets -- -D warnings` ×2
|
||||
(default + `--all-features`) plus the combo builds (`tunnel,git` /
|
||||
`tunnel,session` / `tunnel,sftp`); `cargo fmt --check`; `cargo doc
|
||||
--no-deps` (0 warnings); `taskgraph validate` (26 tasks).
|
||||
+186
-11
@@ -76,7 +76,7 @@ use std::sync::Arc;
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
use russh::client::{AuthResult, Handle, Msg};
|
||||
use russh::keys::PrivateKeyWithHashAlg;
|
||||
use russh::{Channel, ChannelMsg, Disconnect, Error, MethodSet};
|
||||
use russh::{Channel, ChannelMsg, ChannelOpenFailure, Disconnect, Error, MethodSet};
|
||||
|
||||
use alkssh::{
|
||||
AdapterError, HostKeyPolicy, Limits, PresentedHostKey, ServeConfig, SshAdapter, fingerprint,
|
||||
@@ -112,20 +112,86 @@ pub fn client_config(limits: Limits) -> Arc<russh::client::Config> {
|
||||
})
|
||||
}
|
||||
|
||||
/// One server-initiated forwarded open the rows observe at the
|
||||
/// harness client (`w3-tunnel-listen`'s accept-hook harness row — the
|
||||
/// `-R` data leg arrives here). The `Channel` only carries data after
|
||||
/// the row accepts (the harness hook answers the open itself).
|
||||
#[derive(Debug)]
|
||||
pub enum ForwardedOpen {
|
||||
/// The `forwarded-tcpip` row — the echo args + originator the door
|
||||
/// opened with, verbatim as the wire carried them.
|
||||
Tcpip {
|
||||
connected_address: String,
|
||||
connected_port: u32,
|
||||
originator_address: String,
|
||||
originator_port: u32,
|
||||
channel: Channel<Msg>,
|
||||
},
|
||||
/// The `forwarded-streamlocal@openssh.com` row (the unix listen
|
||||
/// leg's arrival shape).
|
||||
Streamlocal {
|
||||
socket_path: String,
|
||||
channel: Channel<Msg>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The harness client's acceptor — what a vanilla client's matching
|
||||
/// `-R` listener stands-in as: when installed, server-initiated
|
||||
/// forwarded opens are accepted and handed to the row through the
|
||||
/// channel the acceptor was built with; **no acceptor installed → the
|
||||
/// open declines cleanly** (`AdministrativelyProhibited` — the
|
||||
/// family's client-side contract, ADR-003 symmetry: a vanillla stand-
|
||||
/// in with no matching forward never accept-and-drops).
|
||||
pub struct ForwardedAcceptor {
|
||||
opens: tokio::sync::mpsc::UnboundedSender<ForwardedOpen>,
|
||||
}
|
||||
|
||||
impl ForwardedAcceptor {
|
||||
/// Builds the acceptor over its event channel; the receiving half
|
||||
/// rides the rows (`drain_forwarded`).
|
||||
pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<ForwardedOpen>) {
|
||||
let (opens, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Self { opens }, receiver)
|
||||
}
|
||||
|
||||
fn hand_off(&self, open: ForwardedOpen) -> Result<(), Error> {
|
||||
self.opens
|
||||
.send(open)
|
||||
.map_err(|_| Error::ChannelOpenFailure(ChannelOpenFailure::UnknownChannelType))
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's russh client handler — the [`MemHostKeyPolicy`]
|
||||
/// decision double bridged into russh's `check_server_key` hook, with
|
||||
/// the decision input exactly as the wave-4 dial wiring will shape it
|
||||
/// ([`PresentedHostKey`] over the crate's fingerprint path).
|
||||
/// ([`PresentedHostKey`] over the crate's fingerprint path), plus the
|
||||
/// `w3-tunnel-listen` accept hook: the server-initiated forwarded
|
||||
/// opens the contract rows observe (the ADR-003 §4 client-side
|
||||
/// acceptor symmetry).
|
||||
pub struct HarnessClientHandler {
|
||||
policy: Arc<MemHostKeyPolicy>,
|
||||
acceptor: Option<Arc<ForwardedAcceptor>>,
|
||||
}
|
||||
|
||||
impl HarnessClientHandler {
|
||||
/// Builds the handler over a policy double — `with_default(true)`
|
||||
/// for the routine permissive posture, `with_default(false)` (or a
|
||||
/// pinned rule) for the fail-closed rows.
|
||||
/// pinned rule) for the fail-closed rows — with the forwarded
|
||||
/// acceptor absent (opens decline cleanly; install one with
|
||||
/// [`HarnessClientHandler::with_forwarded_acceptor`]).
|
||||
pub fn new(policy: Arc<MemHostKeyPolicy>) -> Self {
|
||||
Self { policy }
|
||||
Self {
|
||||
policy,
|
||||
acceptor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the acceptor (the matching-`-R`-listener stand-in).
|
||||
pub fn with_forwarded_acceptor(self, acceptor: Arc<ForwardedAcceptor>) -> Self {
|
||||
Self {
|
||||
policy: self.policy,
|
||||
acceptor: Some(acceptor),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +213,67 @@ impl russh::client::Handler for HarnessClientHandler {
|
||||
};
|
||||
Ok(self.policy.decide(&presented).await)
|
||||
}
|
||||
|
||||
/// The `forwarded-tcpip` accept hook: accept-into-the-acceptor
|
||||
/// (the row observes the echoed args + originator verbatim and
|
||||
/// pumps the `Channel`), or the clean decline when no acceptor is
|
||||
/// installed.
|
||||
async fn server_channel_open_forwarded_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
connected_address: &str,
|
||||
connected_port: u32,
|
||||
originator_address: &str,
|
||||
originator_port: u32,
|
||||
reply: russh::client::ChannelOpenHandle,
|
||||
_session: &mut russh::client::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
match self.acceptor.as_ref() {
|
||||
Some(acceptor) => {
|
||||
let open = ForwardedOpen::Tcpip {
|
||||
connected_address: connected_address.to_owned(),
|
||||
connected_port,
|
||||
originator_address: originator_address.to_owned(),
|
||||
originator_port,
|
||||
channel,
|
||||
};
|
||||
acceptor.hand_off(open)?;
|
||||
reply.accept().await;
|
||||
}
|
||||
None => {
|
||||
reply
|
||||
.reject(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `forwarded-streamlocal` accept hook: the unix leg's shape.
|
||||
async fn server_channel_open_forwarded_streamlocal(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
socket_path: &str,
|
||||
reply: russh::client::ChannelOpenHandle,
|
||||
_session: &mut russh::client::Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
match self.acceptor.as_ref() {
|
||||
Some(acceptor) => {
|
||||
let open = ForwardedOpen::Streamlocal {
|
||||
socket_path: socket_path.to_owned(),
|
||||
channel,
|
||||
};
|
||||
acceptor.hand_off(open)?;
|
||||
reply.accept().await;
|
||||
}
|
||||
None => {
|
||||
reply
|
||||
.reject(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles the server side from the wave-1 doubles — a
|
||||
@@ -218,16 +345,28 @@ pub async fn drive(
|
||||
adapter: SshAdapter,
|
||||
host_key_policy: Arc<MemHostKeyPolicy>,
|
||||
limits: Limits,
|
||||
) -> Result<LoopbackSession, Error> {
|
||||
drive_with_acceptor(adapter, host_key_policy, limits, None).await
|
||||
}
|
||||
|
||||
/// [`drive`] with the client-side forwarded acceptor installed — the
|
||||
/// `w3-tunnel-listen` rows' vehicle (the harness handler's accept
|
||||
/// hook consumes the installed acceptor; `None` = the clean-decline
|
||||
/// posture [`drive`] carries).
|
||||
pub async fn drive_with_acceptor(
|
||||
adapter: SshAdapter,
|
||||
host_key_policy: Arc<MemHostKeyPolicy>,
|
||||
limits: Limits,
|
||||
acceptor: Option<Arc<ForwardedAcceptor>>,
|
||||
) -> Result<LoopbackSession, Error> {
|
||||
let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY);
|
||||
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
|
||||
|
||||
let client = russh::client::connect_stream(
|
||||
client_config(limits),
|
||||
client_io,
|
||||
HarnessClientHandler::new(Arc::clone(&host_key_policy)),
|
||||
)
|
||||
.await?;
|
||||
let mut handler = HarnessClientHandler::new(Arc::clone(&host_key_policy));
|
||||
if let Some(acceptor) = acceptor {
|
||||
handler = handler.with_forwarded_acceptor(acceptor);
|
||||
}
|
||||
let client = russh::client::connect_stream(client_config(limits), client_io, handler).await?;
|
||||
|
||||
Ok(LoopbackSession {
|
||||
client,
|
||||
@@ -353,7 +492,9 @@ pub async fn request_agent_forward(channel: &Channel<Msg>) -> Result<(), Error>
|
||||
|
||||
/// Requests a `tcpip-forward` listen (`-R` ingress) — the
|
||||
/// success/failure shape is the result verbatim
|
||||
/// ([`Error::RequestDenied`] on a declined listen).
|
||||
/// ([`Error::RequestDenied`] on a declined listen; a `port = 0`
|
||||
/// request's actually-bound number rides the success value — sshd
|
||||
/// parity).
|
||||
pub async fn tcpip_forward(
|
||||
client: &Handle<HarnessClientHandler>,
|
||||
address: &str,
|
||||
@@ -362,6 +503,40 @@ pub async fn tcpip_forward(
|
||||
client.tcpip_forward(address, port).await
|
||||
}
|
||||
|
||||
/// Requests a `cancel-tcpip-forward`.
|
||||
pub async fn cancel_tcpip_forward(
|
||||
client: &Handle<HarnessClientHandler>,
|
||||
address: &str,
|
||||
port: u32,
|
||||
) -> Result<(), Error> {
|
||||
client.cancel_tcpip_forward(address, port).await
|
||||
}
|
||||
|
||||
/// Requests a `streamlocal-forward` listen (the unix `-R` ingress).
|
||||
pub async fn streamlocal_forward(
|
||||
client: &Handle<HarnessClientHandler>,
|
||||
socket_path: &str,
|
||||
) -> Result<(), Error> {
|
||||
client.streamlocal_forward(socket_path).await
|
||||
}
|
||||
|
||||
/// Requests a `cancel-streamlocal-forward`.
|
||||
pub async fn cancel_streamlocal_forward(
|
||||
client: &Handle<HarnessClientHandler>,
|
||||
socket_path: &str,
|
||||
) -> Result<(), Error> {
|
||||
client.cancel_streamlocal_forward(socket_path).await
|
||||
}
|
||||
|
||||
/// Drains the acceptor's event channel — the row's observation shape
|
||||
/// (`None` once the acceptor's sender went, i.e. after the session's
|
||||
/// end).
|
||||
pub async fn next_forwarded_open(
|
||||
receiver: &mut tokio::sync::mpsc::UnboundedReceiver<ForwardedOpen>,
|
||||
) -> Option<ForwardedOpen> {
|
||||
receiver.recv().await
|
||||
}
|
||||
|
||||
/// Observes the next channel message — `None` after the channel has
|
||||
/// closed (russh's EOF-of-conversation shape).
|
||||
pub async fn next_message(channel: &mut Channel<Msg>) -> Option<ChannelMsg> {
|
||||
|
||||
@@ -425,3 +425,52 @@ impl HostKeyPolicy for MemHostKeyPolicy {
|
||||
verdict
|
||||
}
|
||||
}
|
||||
|
||||
/// The leg-2 rows' tracing visibility (`w3-tunnel-listen`): a minimal
|
||||
/// hand-rolled global [`tracing::Subscriber`] printing every event's
|
||||
/// field pairs to stderr — the same hand-rolled convention the
|
||||
/// crate-side vocabulary rows use (no `tracing-subscriber`
|
||||
/// dependency; events fire from spawned tasks on both sides of the
|
||||
/// fabric, so the global subscriber is the only capture shape that
|
||||
/// works). Rows opt in: `trace_global()` once per row.
|
||||
pub fn trace_global() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
struct TraceSubscriber(AtomicU64);
|
||||
struct FieldPrinter(u64);
|
||||
|
||||
impl tracing::field::Visit for FieldPrinter {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
eprintln!("trace[{}]: {} = {value:?}", self.0, field.name());
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
eprintln!("trace[{}]: {} = {value:?}", self.0, field.name());
|
||||
}
|
||||
}
|
||||
|
||||
impl tracing::Subscriber for TraceSubscriber {
|
||||
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
|
||||
tracing::Id::from_u64(1)
|
||||
}
|
||||
|
||||
fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {}
|
||||
|
||||
fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {}
|
||||
|
||||
fn event(&self, event: &tracing::Event<'_>) {
|
||||
event.record(&mut FieldPrinter(self.0.fetch_add(1, Ordering::Relaxed)));
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &tracing::Id) {}
|
||||
fn exit(&self, _span: &tracing::Id) {}
|
||||
fn clone_span(&self, _id: &tracing::Id) -> tracing::Id {
|
||||
tracing::Id::from_u64(1)
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tracing::subscriber::set_global_default(TraceSubscriber(AtomicU64::new(0)));
|
||||
}
|
||||
+38
-10
@@ -261,23 +261,51 @@ async fn x11_open_decline_emits_the_never_feature_event() {
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Global-request rows (the `-R`
|
||||
/// listen form declines — the event carries no channel id: global
|
||||
/// requests have none).
|
||||
/// listen form declines this build's flow — the event carries no
|
||||
/// channel id: global requests have none). The reason is the
|
||||
/// build's gate: `unfeatured` where the feature is off,
|
||||
/// `seam-unconfigured` where the tunnel feature is on but no
|
||||
/// fabric leg/listen seam is configured (the unset-declines
|
||||
/// posture — a bare `authenticated()` session configures neither).
|
||||
#[cfg(not(feature = "tunnel"))]
|
||||
#[tokio::test]
|
||||
async fn tcpip_forward_decline_emits_the_global_request_event() {
|
||||
let session = authenticated("forward-row").await;
|
||||
tcpip_forward(&session.client, "127.0.0.1", 8080)
|
||||
.await
|
||||
.expect_err("the listen request declines");
|
||||
let event = await_event(&[
|
||||
("row", "tcpip_forward"),
|
||||
("decline_class", "request"),
|
||||
("reason", "unimplemented"),
|
||||
])
|
||||
.await;
|
||||
assert_decline(
|
||||
&event,
|
||||
&[("row", "tcpip_forward"), ("reason", "unimplemented")],
|
||||
&await_event(&[
|
||||
("row", "tcpip_forward"),
|
||||
("decline_class", "request"),
|
||||
("reason", "unfeatured"),
|
||||
])
|
||||
.await,
|
||||
&[("row", "tcpip_forward"), ("reason", "unfeatured")],
|
||||
);
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* mapping.md §Global-request rows + the
|
||||
/// decline-matrix's config-driven notes (the `w3-tunnel-listen`
|
||||
/// migrated stub row): a featured build whose fabric leg/listen
|
||||
/// seam is unconfigured declines `seam-unconfigured` — the feature
|
||||
/// alone configures nothing.
|
||||
#[cfg(feature = "tunnel")]
|
||||
#[tokio::test]
|
||||
async fn tcpip_forward_decline_emits_the_seam_unconfigured_event() {
|
||||
let session = authenticated("forward-row").await;
|
||||
tcpip_forward(&session.client, "127.0.0.1", 8080)
|
||||
.await
|
||||
.expect_err("the unconfigured listen declines");
|
||||
assert_decline(
|
||||
&await_event(&[
|
||||
("row", "tcpip_forward"),
|
||||
("decline_class", "request"),
|
||||
("reason", "seam-unconfigured"),
|
||||
])
|
||||
.await,
|
||||
&[("row", "tcpip_forward"), ("reason", "seam-unconfigured")],
|
||||
);
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
+1216
-11
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user