composed: the bind-first accept — the listener on a listen resource

Lands the composed accept (ADR-007 variant 2 / ADR-008): the BIND
accept whose listener lives on an alktunnels 0.2.0 bind-first listen
resource, at the graduation's vanilla fidelity.

- `composed::bindfirst_accept` — the BindAcceptFactory: opens the
  listen channel (reply#1 advertises the open-op reply's real
  `bound`, the 0.8.0 reply projection — no expected-addr stand-in),
  inserts the minted `listen` token into the correlation registry,
  and returns the accept future parked on the token's oneshot.
- `composed::register_forwarded_serving` — the forwarded-op serving
  half on the SOCKS5 producer's connection: the shared handler
  correlates by token and owns the middle pump (either-EOF
  completion; a three-party per-direction shutdown cascade would
  deadlock).
- The Sync-bound wrinkle: `SyncHalves`/`OwnedWrite` (mutex
  Sync-proof, no unsafe) over the forwarded halves; the owner
  (`ForwardedOwner`, the TunnelListener) rides inside the write
  payload — dropping the handle drops the session, halves, and
  listener together (teardown structural, compiler-enforced).
- `tests/composed-accept.rs`: the full story (real bound, real peer,
  raw pass-through round trip), client-gone teardown drop-witness,
  distinct tokens for concurrent BINDs, per-accept isolation of a
  FORBIDDEN forwarded open, late-forwarded correlation miss.
- The producer-side adopted forwarded entry rides the per-adopter
  contract (transport-EOF reap), as upstream's bind-first suites
  concede; the no-leak probe reflects that shape.

Verification: cargo test (default + --all-features), wasm
check/clippy (hard gate), clippy --all-targets --all-features -D
warnings, fmt --check — all clean.
This commit is contained in:
glm-5.3-flash committed 2026-09-25 22:13:17 +00:00
1 parent 3128d2bd4f
commit 4e1673eefc
5 files changed
+1284 -24

No files matched your search

+449 -20
View File
@@ -1,8 +1,11 @@
//! The `composed` feature's egress provider (ADR-008's composed
//! variants, ADR-009's feature gate): [`direct_egress`] — the UDP
//! ASSOCIATE egress that dials each destination through an alktunnels
//! 0.2.0 direct-op channel ([`alktunnels::TunnelSession::open_direct`],
//! `Substrate::Udp`).
//! The `composed` feature's egress + accept providers (ADR-008's
//! composed variants, ADR-009's feature gate): [`direct_egress`] — the
//! UDP ASSOCIATE egress that dials each destination through an
//! alktunnels 0.2.0 direct-op channel
//! ([`alktunnels::TunnelSession::open_direct`], `Substrate::Udp`) —
//! and [`bindfirst_accept`] — the TCP BIND accept whose listener lives
//! on a bind-first listen resource (ADR-007 variant 2 / ADR-008's
//! composed accept).
//!
//! **Per-target ACL for free** (ADR-008's composed benefit): each
//! destination rides its own `channels/tunnel/direct`-governed channel
@@ -49,30 +52,44 @@
//! **The Sync-bound wrinkle** (the batch-1 review's seam contract vs
//! alktunnels' surface): the landed `TargetHandle`/`BindTargetHandle`
//! halves are `Box<dyn AsyncRead + Send + Sync + Unpin>`, but
//! alktunnels' `TakenHalves` yields `Send + Unpin` only. The
//! owner-wrapper posture (egress.rs's documented contract: "keep-alive
//! state a substrate needs rides inside the halves — a provider-side
//! owner wrapper") is designed here as [`SessionOwner`] and shared
//! with the composed accept (the sibling task): an alktunnels half
//! wrapped with its session inside, so the handle payload's `Sync`
//! bound is met by ownership and the channel entry reaps when the
//! wrapper (and its session) drops. For the egress the wrapper is not
//! the handle payload's burden — the egress owns the session in the
//! table entry (the POC's shape); the relay task owns the bare read
//! half. The accept variant (the sibling task) wraps the accepted
//! halves in this wrapper.
//! alktunnels' `TakenHalves` yields `Send + Unpin` only — and
//! `alkcall::BiStream` is `Send + Unpin` but NOT `Sync` (its inner is
//! a `dyn AsyncReadWrite` trait object). The owner-wrapper posture
//! (egress.rs's documented contract: "keep-alive state a substrate
//! needs rides inside the halves — a provider-side owner wrapper") is
//! designed here as [`SessionOwner`] and shared with the composed
//! accept (the sibling task): an alktunnels half wrapped with its
//! session inside, so the handle payload's `Sync` bound is met by
//! ownership and the channel entry reaps when the wrapper (and its
//! session) drops. For the egress the wrapper is not the handle
//! payload's burden — the egress owns the session in the table entry
//! (the POC's shape); the relay task owns the bare read half. The
//! accept variant meets the `+ Sync` bound with the [`SyncHalves`]
//! adapter (a `std::sync::Mutex` around the joined halves — a
//! `Mutex<T: Send>` is `Sync`, no unsafe) with the channel session
//! owned inside the payloads ([`ForwardedOwner`] — dropping the handle
//! drops the session AND the halves together).
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use alkcall::channels::client::ChannelClient;
use alktunnels::params::{Substrate, SubstrateAddr};
use alktunnels::TunnelSession;
use alkcall::channels::operations::ChannelCore;
use alkcall::channels::pump::pump_bidi;
use alkcall::core::auth::AuthContext;
use alkcall::core::types::BiStream;
use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams};
use alktunnels::{TunnelListener, TunnelSession};
use bytes::Bytes;
use futures::future::BoxFuture;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::oneshot;
use crate::egress::{Egress, EgressError, EgressFactory};
use crate::egress::{
BindAccept, BindAcceptError, BindAcceptFactory, BindAcceptFactoryError, BindTargetHandle,
Egress, EgressError, EgressFactory,
};
pub use crate::rfc1928::util::target_addr::TargetAddr;
use crate::wire::MAX_INNER_LEN;
@@ -404,3 +421,415 @@ pub fn direct_egress(client: Arc<ChannelClient>, max_tunnels: usize) -> EgressFa
})
})
}
// --- The composed accept (ADR-007 variant 2, the sibling task's shape) ------
/// The correlation registry (the assembly-side bookkeeping): `listen`
/// token → the oneshot delivering the forwarded session (the adopted
/// channel's `BiStream` + the forwarded op's `peer` param). One entry
/// per live BIND session; the forwarded handler (the provider's shared
/// opener half) removes and completes the entry on correlation. A
/// dropped receiver (client-gone teardown) makes completion fail — the
/// handler drops the channel stream (teardown-at-birth).
pub type CorrelationRegistry =
Arc<tokio::sync::Mutex<HashMap<u32, oneshot::Sender<ForwardedSession>>>>;
/// What a correlated forwarded session delivers: the forwarded
/// channel's `BiStream` (raw pass-through — the SOCKS5 BIND data plane
/// is raw on both ends) + the forwarded op's `peer` param (reply#2's
/// real peer — the graduation's win). Public because the
/// [`CorrelationRegistry`] it travels through is public API surface
/// (the assembly holds the handle [`register_forwarded_serving`]
/// returns); constructing one is the forwarded handler's job alone.
pub struct ForwardedSession {
pub read: tokio::io::ReadHalf<tokio::io::DuplexStream>,
pub write: tokio::io::WriteHalf<tokio::io::DuplexStream>,
pub peer: SubstrateAddr,
}
/// The forwarded-op serving handler (the provider's shared opener
/// half): correlates the arriving forwarded open by `listen` token and
/// OWNS the data-plane pump (R-02 — the `register_openable` wrapper
/// awaits this task inline, then tears the forwarded channel down; a
/// handler that returns early reaps the channel at birth). The pump
/// bridges the forwarded channel's `BiStream` to a local duplex pair:
/// the near end pumps here (this task lives exactly as long as the
/// data plane — either end's EOF ends it), the far end travels through
/// the token's oneshot into the accept future's [`BindTargetHandle`]
/// payloads (dropping the handle drops the far end → the pump sees EOF
/// → the task returns → the wrapper reaps the forwarded channel —
/// teardown-at-birth, structural). A correlation miss (no waiting
/// session — client gone, or a token nobody minted) drops the stream:
/// the task returns immediately, the same reaping shape. A dead
/// oneshot receiver makes the send fail — the task drops the stream
/// and returns (same reaping).
fn make_forwarded_handler(registry: CorrelationRegistry) -> alktunnels::ForwardedHandler {
Arc::new(move |forwarded: TunnelForwardedParams, bidi: BiStream| {
let registry = Arc::clone(&registry);
tracing::debug!(
listen = forwarded.listen,
"composed accept: forwarded open arrived"
);
tokio::spawn(async move {
let tx = registry.lock().await.remove(&forwarded.listen);
let Some(tx) = tx else {
tracing::warn!(
listen = forwarded.listen,
"composed accept: forwarded open with no waiting session; dropping"
);
return;
};
let (near, far) = tokio::io::duplex(64 * 1024);
let (far_read, far_write) = tokio::io::split(far);
if tx
.send(ForwardedSession {
read: far_read,
write: far_write,
peer: forwarded.peer,
})
.is_err()
{
tracing::warn!(
listen = forwarded.listen,
"composed accept: waiting session vanished; dropping the forwarded channel"
);
return;
}
// The middle pump (three-party half-close would cycle:
// pump_bidi's per-direction shutdown-on-completion
// deadlocks a bridge — the SOCKS5 pump's far.write shutdown
// needs near.read EOF; the wire's bidi.read EOF needs the
// producer pump's far.read EOF; far.read needs near.write
// shutdown; near.write needs bidi.read EOF). The bridge
// therefore completes on EITHER direction's EOF and drops
// everything: the far halves (the SOCKS5 pump sees EOF),
// the near halves, and the BiStream (the forwarded channel
// tears down; the wrapper's inline await returns — R-02 —
// and reaps the entry).
let (near_read, near_write) = tokio::io::split(near);
pump_bidi(bidi, near_read, near_write).await;
})
})
}
/// The forwarded channel's `BiStream` reassembled after the
/// payload-split: the read half rides the [`SyncHalves`] adapter (the
/// mutex-joined form), the write half rides [`OwnedWrite`] — and the
/// owner rides inside the write payload so dropping the handle drops
/// everything: the teardown-at-birth discipline, compiler-enforced.
/// The owner is the [`TunnelListener`] (NOT an adopted session — the
/// forwarded channel's entry is the producer's, adopted inside the
/// producer's `ForwardedOpener`): the listener's keep-alive holds the
/// far accept loop (and every per-accept pump it spawned — R-02)
/// alive; dropping the handle drops the listener → the listen channel
/// EOFs → the far teardown cascades. The edition-2021 disjoint-capture
/// trap (an earlier draft took `listener.channel_id` — a `u32` — and
/// dropped the listener at the factory's return, EOF-ing the listen
/// channel at birth) is the reason the listener is forced into the
/// future with a whole-struct rebinding.
struct ForwardedOwner {
_listener: TunnelListener,
}
/// The `BindTargetHandle` read-half adapter over the forwarded
/// channel's read half: `BiStream` is `Send + Unpin` but NOT `Sync`
/// (its inner is a `dyn AsyncReadWrite` trait object — no `Sync`
/// supertrait), so the raw halves cannot meet the handle payloads'
/// `+ Sync` bound directly. A `std::sync::Mutex` guard is the
/// no-unsafe Sync proof (`Mutex<T: Send>` is `Sync`); the RFC 1928
/// BIND data plane is a raw pass-through pumped by ONE `pump_bidi`
/// task at a time, so contention never occurs at runtime — the mutex
/// is the Sync-proof shape, not a concurrency mechanism. A poisoned
/// lock (a panic in one poll) unwraps via `into_inner` — a panic in
/// one operation does not cascade (AGENTS.md convention 2).
struct SyncHalves {
inner: std::sync::Mutex<tokio::io::ReadHalf<tokio::io::DuplexStream>>,
}
impl AsyncRead for SyncHalves {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.get_mut();
let mut inner = match this.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
std::pin::Pin::new(&mut *inner).poll_read(cx, buf)
}
}
/// The write-half payload: the owner ([`ForwardedOwner`] — the
/// adopted channel session) and the write half ride inside one
/// `std::sync::Mutex` (the same no-unsafe Sync proof as
/// [`SyncHalves`] — `TunnelSession` and `WriteHalf<BiStream>` are
/// `Send` but not `Sync`). Contention never occurs at runtime (one
/// pump task); a poisoned lock unwraps via `into_inner`.
struct OwnedWrite {
inner: std::sync::Mutex<OwnedInner>,
}
struct OwnedInner {
_owner: ForwardedOwner,
write: tokio::io::WriteHalf<tokio::io::DuplexStream>,
}
impl AsyncWrite for OwnedWrite {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
let this = self.get_mut();
let mut inner = match this.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
std::pin::Pin::new(&mut inner.write).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.get_mut();
let mut inner = match this.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
std::pin::Pin::new(&mut inner.write).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.get_mut();
let mut inner = match this.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
std::pin::Pin::new(&mut inner.write).poll_shutdown(cx)
}
}
/// The forwarded op's serving registration (the opener half — ADR-008
/// §3, `register_tunnel_forwarded_openable` + this provider's
/// correlation handler): register on the SOCKS5 producer's serving
/// registry so its connection serves the forwarded op. Call once per
/// channels connection at assembly time — the registry is shared
/// across all BIND sessions every [`bindfirst_accept`] factory over
/// the same client fabricates (one token space per producer
/// connection).
///
/// The scope gate is `tunnel:forwarded` on the PRODUCER's per-call
/// identity (the ingress grant — ADR-006 Amendment 1; never implied by
/// `tunnel:open`). The returned registry handle feeds the factories;
/// keep it alongside the client they ride.
///
/// # Panics
///
/// If the underlying registration fails (a duplicate
/// `channels/tunnel/forwarded` op id on the registry — an assembly
/// bug, not a runtime condition). `register_openable`'s failure type
/// is a `String` diagnostic, not a typed error.
pub fn register_forwarded_serving(
core: &ChannelCore,
on_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
auth: AuthContext,
) -> CorrelationRegistry {
let registry: CorrelationRegistry = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
alktunnels::producer::register_tunnel_forwarded_openable(
core,
on_registry,
auth,
make_forwarded_handler(Arc::clone(&registry)),
)
.expect("register forwarded openable (the composed accept's serving half)");
registry
}
/// The composed accept factory (ADR-007 variant 2, producer.md
/// §Wiring `composed::bindfirst_accept`): the `BindAcceptFactory` the
/// producer registration consumes — the BIND accept whose listener
/// lives on an alktunnels 0.2.0 bind-first listen resource
/// (`register_tunnel_bindfirst_openable` + `BindFirstInjections` —
/// per-listener ACL for free, hub-relayable).
///
/// Per session the factory:
///
/// 1. Opens the listen channel (`TunnelListener::open` — the
/// establishment bound covers only this open-op round trip,
/// ADR-007); a failed open is a factory failure (reply#1's mapped
/// code — no phantom listener).
/// 2. Advertises reply#1's BND.ADDR as the REAL `bound` (the open-op
/// reply's `bound` field, the alkcall 0.8.0 reply projection — the
/// graduation's vanilla-fidelity win; no expected-addr stand-in).
/// A producer that supplied no `bound`/`listen` (not a bind-first
/// resource) fails the factory — no stand-in on this variant.
/// 3. Inserts the minted `listen` token → oneshot into the
/// correlation registry and returns the accept future parked on
/// it.
///
/// The accept future waits for the ONE forwarded session matching the
/// token (the correlation registry is assembly-side bookkeeping — the
/// listener does not own the receipt queue), then builds the
/// [`BindTargetHandle`]: reply#2's peer is the forwarded op's `peer`
/// param (the graduation's win — no sentinel), and the data-plane
/// halves are the [`SyncHalves`]/[`OwnedWrite`] adapters over the
/// adopted channel's `BiStream` with the [`ForwardedOwner`] (the
/// adopted channel session) riding inside the write payload — dropping
/// the handle drops the session AND the halves together.
///
/// **The client-gone teardown** (the seam contract, end-to-end): the
/// listener and the oneshot receiver ride the accept future — the
/// quiet wait's `select!` drop of that future drops the
/// [`TunnelListener`] (its held write half EOFs the listen channel —
/// the far accept dispatcher tears down) AND drops the oneshot
/// receiver (a later forwarded open for that token finds a dead
/// oneshot and drops the channel stream) — no leaked listener, no
/// leaked channel.
///
/// Only TCP-stream listen resources are supported (the SOCKS5 BIND
/// data plane is raw on both ends; a UDP listen substrate would frame
/// — a datagram BIND variant is a different task). A non-TCP `bound`
/// fails the factory (`NotAllowed`).
pub fn bindfirst_accept(
client: Arc<ChannelClient>,
registry: CorrelationRegistry,
resource: impl Into<String>,
) -> BindAcceptFactory {
let resource: Arc<str> = resource.into().into();
Arc::new(move |_target: TargetAddr| {
let client = Arc::clone(&client);
let registry = Arc::clone(&registry);
let resource = Arc::clone(&resource);
Box::pin(async move {
let params = alktunnels::TunnelParams {
resource: resource.to_string(),
substrate: Substrate::Tcp,
};
let listener = TunnelListener::open(client.as_ref(), params)
.await
.map_err(|e| BindAcceptFactoryError::Refused(format!("listen open: {e}")))?;
let bind_addr = match listener.bound() {
Some(SubstrateAddr::HostPort { host, port }) => resolve_bound(host, *port)
.map_err(|e| BindAcceptFactoryError::General(format!("bound render: {e}")))?,
Some(SubstrateAddr::Path { path }) => {
return Err(BindAcceptFactoryError::NotAllowed(format!(
"non-tcp bound address `{path}` — this provider serves TCP listeners only"
)));
}
None => {
return Err(BindAcceptFactoryError::Shortage(
"the producer supplied no `bound` reply field — not a bind-first \
resource; no stand-in on this variant"
.into(),
));
}
};
let token = listener.listen().ok_or_else(|| {
BindAcceptFactoryError::Shortage(
"the producer supplied no `listen` correlation token — not a bind-first \
resource; no stand-in on this variant"
.into(),
)
})?;
let (tx, rx) = oneshot::channel();
registry.lock().await.insert(token, tx);
tracing::debug!(token, "composed accept: registry entry inserted");
let fut = async move {
// Force the whole-struct capture: edition-2021 disjoint
// capture would take `listener.channel_id` (a `u32`)
// and drop the listener itself at the factory's return
// — EOF-ing the listen channel at birth. The listener
// rides the future end-to-end (the seam contract).
let listener = listener;
let ForwardedSession { read, write, peer } = match rx.await {
Ok(session) => session,
Err(_) => {
tracing::warn!(
token,
"composed accept: oneshot sender dropped; listener closing"
);
// The registry entry was removed without
// delivery (the forwarded serving half tore
// down — the whole provider is going away).
// The listener's drop tears the listen
// channel down (the held write half EOFs).
return Err(BindAcceptError::General(
"the forwarded-op serving half went away; the listener closed".into(),
));
}
};
let peer_addr = peer_to_socket_addr(&peer);
Ok(BindTargetHandle {
peer: TargetAddr::Ip(peer_addr),
read: Box::new(SyncHalves {
inner: std::sync::Mutex::new(read),
}) as Box<dyn AsyncRead + Send + Sync + Unpin>,
write: Box::new(OwnedWrite {
inner: std::sync::Mutex::new(OwnedInner {
// The listener rides INSIDE the payload:
// its keep-alive holds the far accept loop
// (and every per-accept pump it spawned —
// R-02) alive for the data plane's
// lifetime; dropping the handle drops the
// listener → the listen channel EOFs →
// the teardown cascades (teardown
// structural, compiler-enforced). The
// edition-2021 disjoint-capture trap (an
// earlier draft took `listener.channel_id`
// — a `u32` — and dropped the listener at
// the factory's return, EOF-ing the listen
// channel at birth) is why the whole
// struct is rebound into the future above.
_owner: ForwardedOwner {
_listener: listener,
},
write,
}),
}),
})
};
Ok(BindAccept::new(bind_addr, fut))
})
})
}
/// Render the `bound` host (an IP literal or an unresolved host name)
/// + port into reply#1's `SocketAddr`:
///
/// - IP literals resolve directly.
/// - A non-IP host fails (reply#1's address is a real socket address
/// on this variant — a hostname bound is the producer's resolution
/// surface, not the advertised address's).
///
/// The parse error is a `String` diagnostic (the factory maps it to
/// `General`).
fn resolve_bound(host: &str, port: u16) -> Result<SocketAddr, String> {
host.parse::<std::net::IpAddr>()
.map(|ip| SocketAddr::new(ip, port))
.map_err(|e| format!("`{host}`: {e}"))
}
/// The forwarded op's `peer` param rendered to reply#2's socket
/// address (the graduation's peer fidelity — the param is the real
/// peer). A `Path` peer (a unix-socket accept) has no RFC-shaped
/// address; the RFC sentinel `0.0.0.0:0` carries it (the data plane
/// is unaffected either way).
fn peer_to_socket_addr(peer: &SubstrateAddr) -> SocketAddr {
match peer {
SubstrateAddr::HostPort { host, port } => host
.parse::<std::net::IpAddr>()
.map(|ip| SocketAddr::new(ip, *port))
.unwrap_or_else(|_| sentinel_peer()),
SubstrateAddr::Path { .. } => sentinel_peer(),
}
}
fn sentinel_peer() -> SocketAddr {
SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
}
+2 -1
View File
@@ -123,7 +123,8 @@ pub use local::{ProducerDoor, ProducerDoorBuilder, ProducerDoorGuard};
#[cfg(feature = "composed")]
pub use composed::{
direct_egress, key_of, substrate_addr_of, ComposedEgress, SessionOwner, DEFAULT_MAX_TUNNELS,
bindfirst_accept, direct_egress, key_of, register_forwarded_serving, substrate_addr_of,
ComposedEgress, CorrelationRegistry, ForwardedSession, SessionOwner, DEFAULT_MAX_TUNNELS,
};
#[cfg(feature = "local")]