diff --git a/src/tunnel_map.rs b/src/tunnel_map.rs index ab6e65d..a4b51bf 100644 --- a/src/tunnel_map.rs +++ b/src/tunnel_map.rs @@ -1,7 +1,9 @@ //! The `direct-tcpip` / `direct-streamlocal` mapping — the tunnel //! branch's route-1 bridge (`w3-tunnel-direct`, ADR-006 decision 1): //! the adapter-side bridge POC-3 stage C proved, re-landed as contract -//! against published alktunnels 0.4.1. +//! against published alktunnels 0.4.1, with the forwarded serving +//! surface adopted to alktunnels 0.5.0's establisher shape (the +//! review-003 W2 adoption — the `ForwardedGate` correlation gate). //! //! ## The route-1 bridge (ADR-006 decision 1) //! @@ -39,7 +41,8 @@ //! — **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: +//! POC-3 §C.4 as the template (the gate adoption rides 0.5.0's +//! establisher variant, above): //! //! 1. **The listen dial** (`tcpip_forward` / `streamlocal_forward`): //! the door maps the vanilla listen request family-ward as @@ -82,12 +85,21 @@ //! 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. +//! channel budget enforced inside alkcall's dispatch — registered +//! in the **establisher variant** (alktunnels 0.5.0's +//! `register_tunnel_forwarded_openable_with_establisher`, the +//! review-003 W2 adoption): the router's correlation lookup rides +//! the establisher's `ForwardedGate`, so a stale/missing token +//! fails the open typed at the call plane (`channel:open_failed`, +//! `unknown_resource`) before any channel is born; the handler's +//! re-lookup keeps the residual-race + +//! cap-admission teardown-at-birth fallback. //! 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 +//! handle + cap); a miss here is the gate-pass residual race — +//! still **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 @@ -216,7 +228,10 @@ 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 alktunnels::producer::{ + ForwardedGate, ForwardedHandler, TunnelEstablishError, + register_tunnel_forwarded_openable_with_establisher, +}; use parking_lot::Mutex; use russh::Channel; use russh::server::{ChannelOpenHandle, Handle}; @@ -431,7 +446,25 @@ impl Default for ListenRouter { /// 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 +/// over the crate's arrival pump. +/// +/// The registration shape is the **establisher variant** +/// (`register_tunnel_forwarded_openable_with_establisher` — alktunnels +/// 0.5.0, review 003 W2's adoption, ADR-008 Amendment 2's recommended +/// form): the router's correlation lookup rides a [`ForwardedGate`] +/// capturing the router, so a stale/missing `listen` token fails the +/// open at alkcall's call plane as a typed `channel:open_failed` +/// (`details.reason == "unknown_resource"`) **before any channel is +/// born** — the phantom-channel class (call-plane success, then +/// handler-side decline) is closed at the open; door-side arrival +/// correlations that pass the gate but lose the narrow gate-pass→ +/// handler-run race, plus every cap-admission decline, still take the +/// handler's teardown-at-birth path (the residual-race posture the +/// base registration also carries). A malformed arrival is likewise +/// typed (`handler_error`) instead of the base shape's +/// success-then-silent-birth-teardown. +/// +/// The registration is /// one-per-op-id-per-registry: alkcall's `register` silently /// replaces an existing entry, so the door **pre-checks the op** — /// a `tunnel/forwarded` registration that already exists (e.g. a @@ -450,6 +483,21 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu return Err("tunnel listen seam configured without a fabric leg".to_string()); }; let router = Arc::new(ListenRouter::default()); + let gate: ForwardedGate = { + let router = Arc::clone(&router); + Arc::new(move |params: &TunnelForwardedParams| { + if router.route_for(params.listen).is_some() { + Ok(()) + } else { + Err(TunnelEstablishError::UnknownResource(format!( + "no accepted listen routes the arrival's token {} \ + — the forwarding session is gone or was never minted \ + (alkssh: stale/missing `-R` token)", + params.listen + ))) + } + }) + }; let handler: ForwardedHandler = { let router = Arc::clone(&router); Arc::new(move |params: TunnelForwardedParams, bidi: BiStream| { @@ -472,10 +520,11 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu alktunnels::params::OP_TUNNEL_FORWARDED )); } - register_tunnel_forwarded_openable( + register_tunnel_forwarded_openable_with_establisher( &core, &seam.registry, AuthContext::anonymous(b"alk/tunnel"), + gate, handler, ) .map_err(|failure| format!("tunnel/forwarded openable registration: {failure}"))?; @@ -491,7 +540,12 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu /// /// 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). +/// wrapper's awaited join ends, the channel reaps). The gate +/// (the establisher's call-plane correlation) misses a stale +/// token before any channel is born; this handler-side re-lookup +/// is the narrow gate-pass→handler-run **residual race** — the +/// same miss class, past the open — and the never-established cap +/// declines below take the same path. /// 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 @@ -686,3 +740,52 @@ pub(crate) struct PendingListen { /// the session's count dies with the handler, per the channels /// cap's same posture). pub(crate) type ListenerCap = ChannelCap; + +#[cfg(test)] +mod tests { + use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams}; + + use super::{ListenRouter, pump_forwarded_arrival}; + + /// The arrival pump's handler-side miss (the narrow gate-pass→ + /// handler-run residual race the establisher shape retains) still + /// teardown-at-births: a token with no route at handler time drops + /// the fabric half — the adoptee reads EOF, never a half-served + /// arrival. + #[tokio::test] + async fn the_pump_residual_miss_teardown_at_births_the_fabric_half() { + let router = ListenRouter::default(); + let (door, fabric_half) = tokio::io::duplex(64 * 1024); + let (reader, writer) = tokio::io::split(door); + + let bidi = alkcall::core::types::BiStream::from_joined(reader, writer); + + let pump = pump_forwarded_arrival( + std::sync::Arc::new(router), + TunnelForwardedParams { + listen: 424_242, + substrate: Substrate::Tcp, + peer: SubstrateAddr::HostPort { + host: "127.0.0.1".to_owned(), + port: 1234, + }, + }, + bidi, + ); + tokio::time::timeout(std::time::Duration::from_secs(10), pump) + .await + .expect("the miss returns in time (the pump drops the half and ends)"); + + let mut adoptee = fabric_half; + let read = tokio::time::timeout( + std::time::Duration::from_secs(10), + tokio::io::AsyncReadExt::read_u8(&mut adoptee), + ) + .await + .expect("the adoptee's EOF is observable in time"); + assert!( + read.is_err(), + "the adopted fabric half EOFs (teardown-at-birth, not a half-serve)" + ); + } +} diff --git a/tests/tunnel_mapping.rs b/tests/tunnel_mapping.rs index cb9f2c9..53c59ef 100644 --- a/tests/tunnel_mapping.rs +++ b/tests/tunnel_mapping.rs @@ -1,9 +1,11 @@ //! The `w3-tunnel-direct` contract rows — the tunnel branch served for //! real: the route-1 bridge (ADR-006 decision 1) lands //! `direct-tcpip`/`direct-streamlocal` through the published alktunnels -//! 0.4.1 seam (`TunnelSession::open_direct` → accept only after the -//! fabric open succeeds → `take_halves()` pumped against the taken -//! channel), observed through the loopback harness against the **fabric +//! 0.5.0 seam (alkssh 0.2.0's W2 adoption: `TunnelSession::open_direct` +//! → accept only after the fabric open succeeds → `take_halves()` +//! pumped against the taken channel; the forwarded serving surface on +//! the establisher shape — the `ForwardedGate` correlation gate), +//! observed through the loopback harness against the **fabric //! double** (`w3-tunnel-direct`'s recorded test composition: the //! POC-3 §C.4 in-process recipe — `ChannelsAdapter` + //! `InstallChannelZero` + the alktunnels openables + a hand-rolled @@ -204,11 +206,11 @@ mod serving { /// Drives the fabric's forwarded-open closure directly: /// `Ok` carries the adopted fabric-side halves (the door - /// allocated the channel — the correlation-miss row's + /// allocated the channel — the gate-pass/residual-race /// teardown-at-birth is then observable as an EOF on the /// `recv` half), `Err` is the door-side refusal (the - /// composed-gate row's ACL answer — the arrival never became - /// anything). + /// unknown-token gate miss / the composed-gate row's ACL + /// answer — the arrival never became anything). /// The next minted correlation token (one per accepted listen, /// in mint order — the rows' real-token vehicle). pub(super) async fn minted_token(&self) -> u32 { @@ -495,8 +497,47 @@ mod serving { let response = call .call(alktunnels::params::OP_TUNNEL_FORWARDED, payload) .await; + // The real opener maps the wire refusal's + // reason through the `From for EstablishmentError` table — the + // typed `unknown_resource` (the gate's miss + // class) must survive the client side here, + // so the double maps the details.reason back + // into the establisher's taxonomy (the flat + // `HandlerError` swallow would erase exactly + // what the gate rows pin). let out = response.result.map_err(|e| { - TunnelEstablishError::HandlerError(format!("forwarded open: {e:?}")) + if e.code == "channel:open_failed" { + match e + .details + .as_ref() + .and_then(|d| d.get("reason")) + .and_then(|r| r.as_str()) + .unwrap_or_default() + { + "unknown_resource" => { + TunnelEstablishError::UnknownResource(e.message.clone()) + } + "dial_failed" => { + TunnelEstablishError::DialFailed(e.message.clone()) + } + "resource_shortage" => { + TunnelEstablishError::ResourceShortage( + e.message.clone(), + ) + } + "handler_error" => { + TunnelEstablishError::HandlerError(e.message.clone()) + } + _ => TunnelEstablishError::HandlerError(format!( + "forwarded open: {e:?}" + )), + } + } else { + TunnelEstablishError::HandlerError(format!( + "forwarded open: {e:?}" + )) + } })?; let id = out.get("channel_id") @@ -1486,26 +1527,19 @@ mod serving { session.unmount().await; } - /// - *Contract stamp:* the arrival pump's correlation-miss class - /// (`w3-tunnel-listen` — teardown-at-birth, never a half-open SSH - /// channel): an arrival whose `listen` token has no route - /// (nothing was ever minted for this door) is dropped by the - /// door-side handler — the open op's channel tears down at - /// birth, and the fabric side's adopted halves EOF immediately. + /// - *Contract stamp:* the forwarded gate's miss class (alktunnels + /// 0.5.0, review 003 W2 adoption) at the alkssh level: an arrival + /// whose `listen` token has no route (nothing was ever minted for + /// this door) fails the open at the call plane as a typed + /// `channel:open_failed` (`TunnelEstablishError::UnknownResource` + /// — the wire's `unknown_resource` reason) **before any channel + /// is born** — the phantom-channel shape (call-plane success, + /// then handler-side decline) is closed at the open: no channel + /// allocation, no adopted halves, no acceptor row. #[tokio::test] - async fn a_correlation_miss_adopts_no_ssh_channel() { + async fn a_correlation_miss_fails_the_open_typed_before_any_channel() { let double = wire_fabric_with_listen(true, true).await; - let provider = Arc::new(MemIdentityProvider::new()); - provider.register( - fixture("alpha").fingerprint, - identity("tunnel-rider", &["ssh"]), - ); - let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default()); - serve.channel_client = Some(Arc::clone(&double.client)); - serve.tunnel_listen_seam = Some(TunnelListenSeam::new(Arc::clone(&double.registry))); - let _adapter = SshAdapter::new(serve) - .await - .expect("the -R wired adapter constructs (the registration is adapter-scope)"); + let (session, mut opens) = listen_session(&double, Limits::default()).await; let params = alktunnels::params::TunnelForwardedParams { listen: 424_242, @@ -1515,20 +1549,95 @@ mod serving { port: 1234, }, }; - let (mut send, mut recv) = double + let outcome = double.open_forwarded_directly(params).await; + let err = match outcome { + Ok(_) => { + panic!("the gate miss fails the open typed — no phantom channel exists to adopt") + } + Err(error) => error, + }; + assert!( + matches!(err, TunnelEstablishError::UnknownResource(_)), + "expected UnknownResource at the call plane, got {err:?}" + ); + + // No arrival rode the acceptor — the open died at the call + // plane; the handler never ran. + tokio::time::timeout(Duration::from_millis(500), opens.recv()) + .await + .expect_err("a gate-missed arrival never opens an SSH channel"); + assert!(opens.is_empty()); + + session.unmount().await; + } + + /// - *Contract stamp:* the forwarded gate's pass posture (alktunnels + /// 0.5.0, review 003 W2 adoption): an arrival whose token is + /// genuinely routed behaves exactly as the base shape did — the + /// open succeeds at the call plane, the door allocates the SSH + /// channel at the acceptor, and the fabric-side halves are the + /// arrival's data plane (the gate adds an establishment phase; + /// the serving flow is unchanged). + #[tokio::test] + async fn a_gate_pass_arrival_serves_the_route_unchanged() { + let double = wire_fabric_with_listen(true, true).await; + let (session, mut opens) = listen_session(&double, Limits::default()).await; + + let port = pick_port().await; + let _bound = tcpip_forward(&session.client, "127.0.0.1", u32::from(port)) + .await + .expect("the listen is minted"); + let token = double.minted_token().await; + + let params = alktunnels::params::TunnelForwardedParams { + listen: token, + substrate: alktunnels::params::Substrate::Tcp, + peer: alktunnels::params::SubstrateAddr::HostPort { + host: "127.0.0.1".to_owned(), + port: 1234, + }, + }; + let (mut send, _recv) = double .open_forwarded_directly(params) .await - .expect("the door-side openable allocated the arrival's channel"); + .expect("the gate-passed arrival adopts its fabric-side halves"); + + // The serving flow is the unchanged shape: the arrival becomes + // an SSH `forwarded-tcpip` open at the acceptor, and data rides + // the fabric half through to it. + let open = tokio::time::timeout(Duration::from_secs(10), opens.recv()) + .await + .expect("the gate-passed arrival observed in time") + .expect("the acceptor's stream stays alive"); + let mut channel = match open { + ForwardedOpen::Tcpip { channel, .. } => channel, + other => panic!("the gate-passed arrival opened something else: {other:?}"), + }; + send.write_all(b"orphan arrival") .await - .expect("the adopted halves still write (the door dropped its end — EOF, not refusal)"); + .expect("the adopted halves still write (the serving flow ran)"); + drop(send); - // The door-side handler dropped the fabric half at birth: the - // EOF arrives bounded, the channel never half-serves. - tokio::time::timeout(Duration::from_secs(10), recv.read_u8()) - .await - .expect("teardown-at-birth is observable in time") - .expect_err("the adoptee's read half EOFs (the channel reapped)"); + let mut seen: Vec = Vec::new(); + tokio::time::timeout(Duration::from_secs(15), async { + while seen.len() < 13 { + match channel.wait().await { + Some(ChannelMsg::Data { data }) => seen.extend_from_slice(&data), + Some(_) => continue, + None => return, + } + } + }) + .await + .expect("the fabric half rides in time"); + assert_eq!( + seen, + b"orphan arrival".to_vec(), + "the gate-passed arrival's data rode the unchanged serving flow" + ); + + session.unmount().await; } /// - *Contract stamp:* AGENTS.md convention 7 (the visible-surface @@ -1537,7 +1646,11 @@ mod serving { /// caller's identity — an identity lacking the /// `tunnel:forwarded` scope is refused by alkcall's dispatch (the /// openable's own spec) and no SSH channel opens, even for a - /// genuinely minted token. + /// genuinely minted token. The genuinely-minted-token shape + /// double-pins the gate ordering now (the establisher variant's + /// pinned order, alktunnels 0.5.0 / ADR-008 Amendment 2): the ACL + /// refuses before the establisher's gate ever looks up the token — + /// an unscoped arrival could not leak a gate pass. #[tokio::test] async fn the_door_side_acl_refuses_an_unscoped_arrival() { let double = wire_fabric_with_listen(true, false).await;