- tests/assembly-integration.rs (7 tests, local + composed): the full -D story (vanilla cred-bearing client -> TCP door + UDP door -> producer w/ local providers -> real echo targets; CONNECT + BIND + ASSOCIATE live, one-command-per-connection, teardown + fresh sessions), the composed story (direct_egress + bindfirst_accept + real TCP dial; all three commands; the lazy-table dial-count witness), mixed assembly (channels-native + door clients on one registration; composed seams beside native sessions), the assembly teardown matrix (UDP-door idle bound; channel reaped; fresh association after), the producer door driving all three commands, and the -D domain-target pin (resolution at the producer's seam, ADR-012). - Fix the drift the suite surfaced: ComposedEgress::next_reply ended fresh (never-opened-table) associations at birth — the ended-relay Err now fires only once a tunnel existed and every relay ended; the fresh table parks (the readiness contract). Regression-pinned in tests/composed-egress.rs (fresh_association_parks_before_any_ tunnel_is_opened). - Harness: the composed-accept machinery single-sourced into tests/harness.rs (the composed suites' shapes + the five-gate composed identity); composed-accept.rs consumes the shared shapes. - Docs sync: README Current State (the assembly layer landed; both suites named), lib.rs crate docs (the assembly-layer paragraph), consumer.md (the TokenClient door-only contract + the one-command-per-connection posture reconciled). - Task assembly-integration-suite: completed; Notes + Summary filled (the local-on-wasm failure observed: mio's unsupported-wasm error). Verification: cargo test (375), cargo test --all-features (615), clippy --all-targets -D warnings (default + all-features), fmt --check, doc --no-deps (0 warnings), both wasm checks (default + composed), local-on-wasm fails as designed.
1502 lines
56 KiB
Rust
1502 lines
56 KiB
Rust
//! The cross-cutting assembly integration suite — the whole assembly
|
|
//! layer's behavior pinned in one place (the batch-2 closing task; the
|
|
//! architecture-sync check's test half).
|
|
//!
|
|
//! The per-component tasks own their verticals (tests/door-*.rs,
|
|
//! tests/local-*.rs, tests/composed-*.rs); this suite owns the
|
|
//! compositions and the cross-shape invariants:
|
|
//!
|
|
//! - **The full `-D` story end to end**: a vanilla tun2proxy-shaped
|
|
//! client → the cred-bearing TCP door → the channels-native session
|
|
//! → the producer with `local` providers → real echo targets —
|
|
//! CONNECT + UDP (the RFC-header'd datagram through the UDP door) +
|
|
//! BIND (the vanilla BIND conversation through the door) all through
|
|
//! one door process against one producer registration (the
|
|
//! one-registration-three-commands regression, now through the
|
|
//! doors).
|
|
//! - **The composed story end to end**: the producer with
|
|
//! `composed::direct_egress` + `composed::bindfirst_accept` —
|
|
//! CONNECT through the direct op, ASSOCIATE through the lazy table,
|
|
//! BIND through the bind-first listener (the ADR-008 composed
|
|
//! variant set, all three commands).
|
|
//! - **Mixed assembly**: one producer registration serving
|
|
//! channels-native sessions AND door clients AND composed seams
|
|
//! concurrently (the producer/consumer-roles-not-sides posture —
|
|
//! both shapes on one connection).
|
|
//! - **The teardown matrix, assembly edition**: door-client disconnect
|
|
//! tears the association/channel down (no leaks — the channel count
|
|
//! witnesses); the idle bounds fire; fresh sessions succeed
|
|
//! immediately after each teardown (no connection poisoning).
|
|
#![cfg(all(feature = "local", feature = "composed"))]
|
|
|
|
mod harness;
|
|
|
|
fn init_trace() {
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_max_level(tracing::level_filters::LevelFilter::DEBUG)
|
|
.with_test_writer()
|
|
.try_init();
|
|
}
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use alkcall::channels::operations::ChannelCore;
|
|
use alkcall::core::auth::{Identity, IdentityProvider};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
|
use tokio::time::timeout;
|
|
|
|
use alksocks::composed::{bindfirst_accept, direct_egress, register_forwarded_serving};
|
|
use alksocks::consumer::{AssociateSession, BindSession, Socks5ChannelSession};
|
|
use alksocks::rfc1928::consts;
|
|
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
|
|
use alksocks::{CredentialTable, DoorCredential, FrontDoor, UdpFrontDoor, SOCKS5_BIND_SCOPE};
|
|
|
|
use harness::{
|
|
composed_full_opener_identity, composed_push_accept, wire_bindfirst_composed,
|
|
wire_consumer_side_with, wire_consumer_side_with_provider, COMPOSED_BOUND_PORT,
|
|
COMPOSED_PEER_PORT, COMPOSED_RESOURCE,
|
|
};
|
|
|
|
const BOUND: Duration = Duration::from_secs(10);
|
|
|
|
// --- Real targets ------------------------------------------------------------
|
|
|
|
/// A real TCP echo server on an ephemeral loopback port.
|
|
async fn echo_tcp_server() -> SocketAddr {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("local_addr");
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
match stream.read(&mut buf).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => {
|
|
if stream.write_all(&buf[..n]).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
addr
|
|
}
|
|
|
|
/// A real UDP echo server on an ephemeral loopback port.
|
|
async fn echo_udp_server() -> SocketAddr {
|
|
let sock = UdpSocket::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("bind udp echo");
|
|
let addr = sock.local_addr().expect("addr");
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 65535];
|
|
loop {
|
|
let Ok((n, peer)) = sock.recv_from(&mut buf).await else {
|
|
return;
|
|
};
|
|
if sock.send_to(&buf[..n], peer).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
addr
|
|
}
|
|
|
|
// --- Vanilla-client helpers (the door boundary's RFC forms) ------------------
|
|
|
|
/// The RFC 1928 cred-bearing conversation's CONNECT: greeting (the
|
|
/// password method offered) → method selection → credentials → the
|
|
/// CONNECT command for a v4 target → the reply → (code, stream, BND).
|
|
/// The vanilla tun2proxy shape (`--proxy socks5://user:pass@host:port`).
|
|
async fn vanilla_cred_connect(door: SocketAddr, target: SocketAddr) -> (TcpStream, u8, [u8; 6]) {
|
|
let mut stream = vanilla_with_creds(door).await;
|
|
let mut req = vec![0x05, 0x01, 0x00, 0x01];
|
|
let SocketAddr::V4(v4) = target else {
|
|
panic!("v4 targets only")
|
|
};
|
|
req.extend_from_slice(&v4.ip().octets());
|
|
req.extend_from_slice(&v4.port().to_be_bytes());
|
|
stream.write_all(&req).await.expect("request");
|
|
stream.flush().await.expect("flush");
|
|
|
|
let mut head = [0u8; 4];
|
|
stream.read_exact(&mut head).await.expect("reply head");
|
|
assert_eq!(head[0], 0x05, "reply is v5");
|
|
assert_eq!(head[3], 0x01, "v4 BND.ADDR");
|
|
let mut addr = [0u8; 6];
|
|
stream.read_exact(&mut addr).await.expect("BND.ADDR");
|
|
(stream, head[1], addr)
|
|
}
|
|
|
|
/// The RFC 1928 NoAuth conversation against a TCP door: greet, send a
|
|
/// CONNECT command for a v4 target, read the reply → (code, stream).
|
|
async fn vanilla_no_auth_connect(door: SocketAddr, target: SocketAddr) -> (TcpStream, u8, [u8; 6]) {
|
|
let mut stream = TcpStream::connect(door).await.expect("door connect");
|
|
stream
|
|
.write_all(&[0x05, 0x01, 0x00])
|
|
.await
|
|
.expect("greeting");
|
|
let mut chosen = [0u8; 2];
|
|
stream
|
|
.read_exact(&mut chosen)
|
|
.await
|
|
.expect("method selection");
|
|
assert_eq!(chosen, [0x05, 0x00], "no-auth selected");
|
|
|
|
let mut req = vec![0x05, 0x01, 0x00, 0x01];
|
|
let SocketAddr::V4(v4) = target else {
|
|
panic!("v4 targets only")
|
|
};
|
|
req.extend_from_slice(&v4.ip().octets());
|
|
req.extend_from_slice(&v4.port().to_be_bytes());
|
|
stream.write_all(&req).await.expect("request");
|
|
stream.flush().await.expect("flush");
|
|
|
|
let mut head = [0u8; 4];
|
|
stream.read_exact(&mut head).await.expect("reply head");
|
|
assert_eq!(head[0], 0x05, "reply is v5");
|
|
assert_eq!(head[3], 0x01, "v4 BND.ADDR");
|
|
let mut addr = [0u8; 6];
|
|
stream.read_exact(&mut addr).await.expect("BND.ADDR");
|
|
(stream, head[1], addr)
|
|
}
|
|
|
|
/// An RFC-header'd datagram (the vanilla client's sendto form — the
|
|
/// door boundary's codec, hand-rolled per the RFC wire form).
|
|
fn rfc_datagram(target: SocketAddr, payload: &[u8]) -> Vec<u8> {
|
|
let SocketAddr::V4(v4) = target else {
|
|
panic!("v4 targets only")
|
|
};
|
|
let mut dg = vec![0u8, 0, 0, 0x01];
|
|
dg.extend_from_slice(&v4.ip().octets());
|
|
dg.extend_from_slice(&v4.port().to_be_bytes());
|
|
dg.extend_from_slice(payload);
|
|
dg
|
|
}
|
|
|
|
/// Parse an RFC-header'd datagram back → (addr, payload).
|
|
fn parse_rfc_datagram(wire: &[u8]) -> (SocketAddr, &[u8]) {
|
|
assert_eq!(&wire[..2], &[0, 0], "RSV zero");
|
|
assert_eq!(wire[3], 0x01, "v4 ATYP");
|
|
let ip = std::net::Ipv4Addr::new(wire[4], wire[5], wire[6], wire[7]);
|
|
let port = u16::from_be_bytes([wire[8], wire[9]]);
|
|
(SocketAddr::from((ip, port)), &wire[10..])
|
|
}
|
|
|
|
// --- The token provider (the cred-bearing door's CF-005 seam) ----------------
|
|
|
|
/// Resolves the door's mapped identity ids (`door-`-prefixed) to
|
|
/// identities carrying the requested scopes; anything else resolves to
|
|
/// none (the CF-005 precedence fall-through).
|
|
#[derive(Clone)]
|
|
struct DoorTokenProvider {
|
|
bind_scoped: Vec<String>,
|
|
}
|
|
|
|
impl IdentityProvider for DoorTokenProvider {
|
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
|
None
|
|
}
|
|
fn resolve_from_token(&self, token: &alkcall::core::auth::AuthToken) -> Option<Identity> {
|
|
let id = String::from_utf8_lossy(&token.raw).into_owned();
|
|
if id.starts_with("door-") {
|
|
let mut scopes = vec![alksocks::SOCKS5_OPEN_SCOPE.to_string()];
|
|
if self.bind_scoped.contains(&id) {
|
|
scopes.push(SOCKS5_BIND_SCOPE.to_string());
|
|
}
|
|
return Some(Identity {
|
|
id,
|
|
scopes,
|
|
resources: Default::default(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
fn cred_table() -> CredentialTable {
|
|
CredentialTable::new(vec![DoorCredential {
|
|
username: "vanilla".to_string(),
|
|
password: "hunter2".to_string(),
|
|
identity: Identity {
|
|
id: "door-vanilla".to_string(),
|
|
scopes: vec![
|
|
alksocks::SOCKS5_OPEN_SCOPE.to_string(),
|
|
SOCKS5_BIND_SCOPE.to_string(),
|
|
],
|
|
resources: Default::default(),
|
|
},
|
|
}])
|
|
}
|
|
|
|
/// The RFC 1929 cred-bearing conversation: greeting (password method
|
|
/// offered) → method selection → credentials → command. Returns the
|
|
/// post-auth stream.
|
|
async fn vanilla_with_creds(door: SocketAddr) -> TcpStream {
|
|
let mut stream = TcpStream::connect(door).await.expect("door connect");
|
|
stream
|
|
.write_all(&[0x05, 0x01, 0x02])
|
|
.await
|
|
.expect("greeting");
|
|
let mut chosen = [0u8; 2];
|
|
stream
|
|
.read_exact(&mut chosen)
|
|
.await
|
|
.expect("method selected");
|
|
assert_eq!(chosen, [0x05, 0x02], "password auth selected");
|
|
let mut packet = vec![0x01, 7];
|
|
packet.extend_from_slice(b"vanilla");
|
|
packet.push(7);
|
|
packet.extend_from_slice(b"hunter2");
|
|
stream.write_all(&packet).await.expect("creds sent");
|
|
let mut status = [0u8; 2];
|
|
stream.read_exact(&mut status).await.expect("status");
|
|
assert_eq!(status, [0x01, 0x00], "RFC 1929 accepted");
|
|
stream
|
|
}
|
|
|
|
// --- The composed suite (the direct-op + bind-first producer) ----------------
|
|
|
|
/// The composed topology: the SOCKS5 producer serves the direct op
|
|
/// (CONNECT + ASSOCIATE's egress) AND the bind-first op (BIND's
|
|
/// listener) on one connection — the ADR-008 composed variant set.
|
|
/// The direct-op dial is a real TCP/UDP-capable stand-in: a TCP dial
|
|
/// dials the real echo target (CONNECT rides it); a UDP dial yields a
|
|
/// framed echo pipe (ASSOCIATE's destinations echo datagrams).
|
|
struct ComposedSuite {
|
|
topo: harness::BindFirstTopology,
|
|
queue: harness::ComposedAcceptQueue,
|
|
dial_count: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
async fn wire_composed_suite() -> ComposedSuite {
|
|
init_trace();
|
|
let resource_registry = alktunnels::producer::ResourceRegistry::new();
|
|
resource_registry
|
|
.register(
|
|
COMPOSED_RESOURCE,
|
|
alktunnels::params::Substrate::Tcp,
|
|
"assembly-owned",
|
|
)
|
|
.await;
|
|
let queue = harness::composed_fresh_queue();
|
|
let dial_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
|
|
// The direct-op dial: a TCP dial rides the real sockets via
|
|
// `local::tcp_dial`-shaped behavior re-expressed on the alktunnels
|
|
// `DialFn` (the direct op's TargetHandle); a UDP dial yields the
|
|
// framed echo pipe (the composed-egress suite's shape).
|
|
let dial = {
|
|
let dial_count = Arc::clone(&dial_count);
|
|
Arc::new(
|
|
move |substrate: alktunnels::params::Substrate,
|
|
backing: &str|
|
|
-> futures::future::BoxFuture<
|
|
'static,
|
|
Result<alktunnels::producer::TargetHandle, alktunnels::TunnelEstablishError>,
|
|
> {
|
|
let dial_count = Arc::clone(&dial_count);
|
|
let backing = backing.to_string();
|
|
Box::pin(async move {
|
|
use alktunnels::params::Substrate as S;
|
|
use alktunnels::producer::TargetHandle as TunnelTargetHandle;
|
|
use alktunnels::TunnelEstablishError;
|
|
dial_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
match substrate {
|
|
S::Tcp => {
|
|
// A real TCP dial: `host:port` → TcpStream → split.
|
|
let (host, port) = backing.rsplit_once(':').ok_or_else(|| {
|
|
TunnelEstablishError::DialFailed("bad target".into())
|
|
})?;
|
|
let addr: SocketAddr =
|
|
format!("{host}:{port}").parse().map_err(|e| {
|
|
TunnelEstablishError::DialFailed(format!("parse: {e}"))
|
|
})?;
|
|
let stream = TcpStream::connect(addr).await.map_err(|e| {
|
|
TunnelEstablishError::DialFailed(format!("connect: {e}"))
|
|
})?;
|
|
let (r, w) = stream.into_split();
|
|
Ok(TunnelTargetHandle {
|
|
read: Box::new(r),
|
|
write: Box::new(w),
|
|
})
|
|
}
|
|
S::Udp => {
|
|
// The framed UDP echo pipe (the composed-egress
|
|
// suite's shape): the "target" decodes the
|
|
// tunnel codec and echoes each datagram.
|
|
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
|
tokio::spawn(async move {
|
|
use tokio::io::AsyncReadExt;
|
|
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
|
let mut reader = alktunnels::wire::DatagramReader::new();
|
|
let mut buf = vec![0u8; 16 * 1024];
|
|
loop {
|
|
let n = match t_read.read(&mut buf).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => n,
|
|
};
|
|
for dg in reader.feed(&buf[..n]) {
|
|
let framed = match alktunnels::wire::frame_datagram(&dg) {
|
|
Ok(f) => f,
|
|
Err(_) => return,
|
|
};
|
|
if t_write.write_all(&framed).await.is_err() {
|
|
return;
|
|
}
|
|
let _ = t_write.flush().await;
|
|
}
|
|
}
|
|
});
|
|
let (c_read, c_write) = tokio::io::split(consumer_side);
|
|
Ok(TunnelTargetHandle {
|
|
read: Box::new(c_read),
|
|
write: Box::new(c_write),
|
|
})
|
|
}
|
|
other => Err(TunnelEstablishError::DialFailed(format!(
|
|
"substrate not wired: {other:?}"
|
|
))),
|
|
}
|
|
})
|
|
},
|
|
) as alktunnels::producer::DialFn
|
|
};
|
|
|
|
let topo = wire_bindfirst_composed(
|
|
resource_registry,
|
|
harness::composed_test_bind(),
|
|
harness::composed_queue_loop(queue.clone()),
|
|
None, // the SOCKS5 openable registers post-hoc, below
|
|
composed_full_opener_identity(),
|
|
)
|
|
.await;
|
|
|
|
// The forwarded serving half rides the opener's registry.
|
|
let registry = register_forwarded_serving(
|
|
&ChannelCore::new(
|
|
topo.opener.manager().clone(),
|
|
alkcall::channels::policy::default_policy(),
|
|
),
|
|
&topo.opener_registry,
|
|
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
|
);
|
|
|
|
// The SOCKS5 producer: the composed accept seam (BIND) + the
|
|
// composed direct-op egress (ASSOCIATE) + a real TCP dial
|
|
// (CONNECT — the direct-op dial's TCP arm shares the real target;
|
|
// the CONNECT dial here is the crate's own local dial over the
|
|
// SOCKS5 seam, distinct from the tunnel dial).
|
|
let factory = bindfirst_accept(topo.opener.clone(), registry, COMPOSED_RESOURCE);
|
|
let core = ChannelCore::new(
|
|
topo.producer_manager.clone(),
|
|
alkcall::channels::policy::default_policy(),
|
|
);
|
|
alksocks::producer::Socks5Producer::builder()
|
|
.dial(alksocks::local::tcp_dial())
|
|
.egress(direct_egress(topo.opener.clone(), 8))
|
|
.accept(factory)
|
|
.build()
|
|
.register(
|
|
&core,
|
|
&topo.producer_registry,
|
|
alkcall::core::auth::AuthContext::anonymous(b"alk/socks5"),
|
|
None,
|
|
)
|
|
.expect("register socks5 openable (the composed suite's producer)");
|
|
|
|
// The direct op rides the producer's registry (the direct-op
|
|
// caller is the opener's client; the dial is the tunnel dial
|
|
// above).
|
|
let direct_core = ChannelCore::new(
|
|
topo.producer_manager.clone(),
|
|
alkcall::channels::policy::default_policy(),
|
|
);
|
|
alktunnels::producer::register_tunnel_direct_openable(
|
|
&direct_core,
|
|
&topo.producer_registry,
|
|
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
|
dial,
|
|
None,
|
|
)
|
|
.expect("register the tunnel direct op (the composed suite)");
|
|
|
|
ComposedSuite {
|
|
topo,
|
|
queue,
|
|
dial_count,
|
|
}
|
|
}
|
|
|
|
// --- The suite -----------------------------------------------------------------
|
|
|
|
/// THE full `-D` story (the one-registration-three-commands regression
|
|
/// through the doors): one producer registration (the consumer-side
|
|
/// topology over the `local` providers) + one cred-bearing TCP door +
|
|
/// one UDP door. A vanilla client drives all three commands through
|
|
/// the doors against the one registration:
|
|
///
|
|
/// - CONNECT: the RFC 1929 door conversation → the CONNECT request →
|
|
/// the real TCP echo target round-trips.
|
|
/// - BIND: the door's BIND conversation (its own RFC session — the
|
|
/// vanilla client's one-command-per-connection shape) → the app
|
|
/// server dials the advertised address → reply#2's real peer → the
|
|
/// data plane.
|
|
/// - ASSOCIATE: the UDP door's relay socket ← the RFC-header'd
|
|
/// datagram → the real UDP echo target → back RFC-header'd.
|
|
///
|
|
/// All three sessions are live simultaneously mid-life of each other;
|
|
/// each teardown ends only its own session. The two doors ride two
|
|
/// channels connections to the same producer registration (the
|
|
/// assembly story: one registration, many connections — the cred-
|
|
/// bearing door's `TokenClient` takes its client's call connection,
|
|
/// the door-only contract, so the UDP door's association rides its
|
|
/// own).
|
|
#[tokio::test]
|
|
async fn full_dash_d_story_one_registration_three_commands_through_the_doors() {
|
|
init_trace();
|
|
let tcp_target = echo_tcp_server().await;
|
|
let udp_target = echo_udp_server().await;
|
|
|
|
// One producer registration, two door connections: the cred-
|
|
// bearing TCP door rides one client (its TokenClient takes the
|
|
// call connection); the UDP door's association rides a second
|
|
// connection to the same registration (the UDP relay is a
|
|
// separate datagram plane by construction — one relay socket = one
|
|
// association).
|
|
let tcp_dial = alksocks::local::tcp_dial();
|
|
let tcp_topo = wire_consumer_side_with_provider(
|
|
tcp_dial,
|
|
Some(alksocks::local::udp_egress()),
|
|
Some(alksocks::local::bind_accept([127, 0, 0, 1])),
|
|
Some(harness::consumer_identity()),
|
|
Arc::new(DoorTokenProvider {
|
|
bind_scoped: vec!["door-vanilla".to_string()],
|
|
}),
|
|
)
|
|
.await;
|
|
let udp_dial = alksocks::local::tcp_dial();
|
|
let udp_topo = wire_consumer_side_with_provider(
|
|
udp_dial,
|
|
Some(alksocks::local::udp_egress()),
|
|
Some(alksocks::local::bind_accept([127, 0, 0, 1])),
|
|
Some(harness::consumer_identity()),
|
|
Arc::new(DoorTokenProvider {
|
|
bind_scoped: vec!["door-vanilla".to_string()],
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// The cred-bearing TCP door (the CONNECT + BIND shape) + the UDP
|
|
// door (the datagram plane) — one door process per plane, the
|
|
// assembly wiring consumer.md documents.
|
|
let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let door_addr = tcp_listener.local_addr().expect("door addr");
|
|
let door = FrontDoor::builder()
|
|
.credentials(cred_table())
|
|
.bind(door_addr)
|
|
.build();
|
|
let _tcp_serve = tokio::spawn({
|
|
let client = Arc::clone(&tcp_topo.consumer);
|
|
async move {
|
|
door.serve_on(tcp_listener, client)
|
|
.await
|
|
.expect("the door serves")
|
|
}
|
|
});
|
|
let udp_relay = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay");
|
|
let udp_relay_addr = udp_relay.local_addr().expect("relay addr");
|
|
let udp_door = UdpFrontDoor::builder().bind(udp_relay_addr).build();
|
|
let _udp_serve = tokio::spawn({
|
|
let client = Arc::clone(&udp_topo.consumer);
|
|
async move {
|
|
udp_door
|
|
.serve_on(udp_relay, client)
|
|
.await
|
|
.expect("the udp door serves")
|
|
}
|
|
});
|
|
|
|
// --- CONNECT through the cred-bearing door (live) ---------------
|
|
let mut connect_stream = timeout(BOUND, vanilla_with_creds(door_addr))
|
|
.await
|
|
.expect("the cred conversation");
|
|
let mut req = vec![0x05, 0x01, 0x00, 0x01];
|
|
let SocketAddr::V4(v4) = tcp_target else {
|
|
panic!("v4 target")
|
|
};
|
|
req.extend_from_slice(&v4.ip().octets());
|
|
req.extend_from_slice(&v4.port().to_be_bytes());
|
|
connect_stream
|
|
.write_all(&req)
|
|
.await
|
|
.expect("connect request");
|
|
let mut head = [0u8; 4];
|
|
timeout(BOUND, connect_stream.read_exact(&mut head))
|
|
.await
|
|
.expect("reply#1")
|
|
.expect("read");
|
|
assert_eq!(
|
|
head[1],
|
|
consts::SOCKS5_REPLY_SUCCEEDED,
|
|
"CONNECT through the door"
|
|
);
|
|
let mut connect_bnd = [0u8; 6];
|
|
timeout(BOUND, connect_stream.read_exact(&mut connect_bnd))
|
|
.await
|
|
.expect("the CONNECT reply's BND.ADDR")
|
|
.expect("read");
|
|
|
|
// --- BIND through the door (live, mid-life of the CONNECT) ------
|
|
// Its own door connection (the vanilla one-command-per-connection
|
|
// shape — the door's RFC half reads one command per conversation).
|
|
let mut bind_stream = timeout(BOUND, vanilla_with_creds(door_addr))
|
|
.await
|
|
.expect("the second cred conversation");
|
|
bind_stream
|
|
.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
|
.await
|
|
.expect("bind request");
|
|
let mut head1 = [0u8; 4];
|
|
timeout(BOUND, bind_stream.read_exact(&mut head1))
|
|
.await
|
|
.expect("bind reply#1")
|
|
.expect("read");
|
|
assert_eq!(
|
|
head1[1],
|
|
consts::SOCKS5_REPLY_SUCCEEDED,
|
|
"BIND through the door"
|
|
);
|
|
let mut bnd = [0u8; 6];
|
|
timeout(BOUND, bind_stream.read_exact(&mut bnd))
|
|
.await
|
|
.expect("reply#1 BND.ADDR")
|
|
.expect("read");
|
|
let advertised = SocketAddr::from((
|
|
[bnd[0], bnd[1], bnd[2], bnd[3]],
|
|
u16::from_be_bytes([bnd[4], bnd[5]]),
|
|
));
|
|
assert!(
|
|
advertised.port() != 0,
|
|
"reply#1 carries the real listener: {advertised}"
|
|
);
|
|
let mut app = timeout(BOUND, TcpStream::connect(advertised))
|
|
.await
|
|
.expect("dial the advertised address")
|
|
.expect("app connected");
|
|
app.write_all(b"vanilla-bind").await.expect("app writes");
|
|
{
|
|
let mut head2 = [0u8; 4];
|
|
timeout(BOUND, bind_stream.read_exact(&mut head2))
|
|
.await
|
|
.expect("bind reply#2 head")
|
|
.expect("read");
|
|
assert_eq!(head2[1], consts::SOCKS5_REPLY_SUCCEEDED, "BIND reply#2");
|
|
let mut peer_addr = [0u8; 6];
|
|
timeout(BOUND, bind_stream.read_exact(&mut peer_addr))
|
|
.await
|
|
.expect("reply#2 DST.ADDR")
|
|
.expect("read");
|
|
let port = u16::from_be_bytes([peer_addr[4], peer_addr[5]]);
|
|
assert_eq!(
|
|
port,
|
|
app.local_addr().expect("app local").port(),
|
|
"reply#2 carries the real peer"
|
|
);
|
|
}
|
|
|
|
// --- ASSOCIATE through the UDP door (live, mid-life of both) ----
|
|
let client_sock = UdpSocket::bind("127.0.0.1:0").await.expect("client socket");
|
|
client_sock
|
|
.send_to(&rfc_datagram(udp_target, b"udp-door"), udp_relay_addr)
|
|
.await
|
|
.expect("vanilla sendto the relay");
|
|
let mut rx = vec![0u8; 65535];
|
|
let (n, from) = timeout(BOUND, client_sock.recv_from(&mut rx))
|
|
.await
|
|
.expect("udp reply within bound")
|
|
.expect("recv");
|
|
assert_eq!(
|
|
from, udp_relay_addr,
|
|
"the reply comes from the relay socket"
|
|
);
|
|
let (src, payload) = parse_rfc_datagram(&rx[..n]);
|
|
assert_eq!(src, udp_target, "the echo reports the target");
|
|
assert_eq!(payload, b"udp-door");
|
|
|
|
// --- The data planes, all three live: exercise each mid-life ----
|
|
connect_stream
|
|
.write_all(b"tcp-live")
|
|
.await
|
|
.expect("tcp write");
|
|
let mut buf = [0u8; 8];
|
|
timeout(BOUND, connect_stream.read_exact(&mut buf))
|
|
.await
|
|
.expect("tcp echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"tcp-live");
|
|
|
|
let mut app_buf = [0u8; 12];
|
|
timeout(BOUND, bind_stream.read_exact(&mut app_buf))
|
|
.await
|
|
.expect("bind data plane")
|
|
.expect("read");
|
|
assert_eq!(&app_buf, b"vanilla-bind");
|
|
|
|
client_sock
|
|
.send_to(&rfc_datagram(udp_target, b"udp-live"), udp_relay_addr)
|
|
.await
|
|
.expect("send");
|
|
let (n, _) = timeout(BOUND, client_sock.recv_from(&mut rx))
|
|
.await
|
|
.expect("udp echo")
|
|
.expect("recv");
|
|
assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"udp-live");
|
|
|
|
// --- Teardown: the door-client disconnect ends only its own -----
|
|
// The CONNECT + BIND connections drop (the vanilla client's
|
|
// disconnect); the association on the UDP door is a separate
|
|
// session.
|
|
drop(connect_stream);
|
|
drop(bind_stream);
|
|
drop(app);
|
|
|
|
// The UDP door's association survives the door-client disconnect
|
|
// (mux/demux isolation): another datagram round-trips.
|
|
client_sock
|
|
.send_to(&rfc_datagram(udp_target, b"still"), udp_relay_addr)
|
|
.await
|
|
.expect("send after the tcp teardown");
|
|
let (n, _) = timeout(BOUND, client_sock.recv_from(&mut rx))
|
|
.await
|
|
.expect("post-teardown echo")
|
|
.expect("recv");
|
|
assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"still");
|
|
|
|
// Fresh sessions of every command succeed immediately after (no
|
|
// connection poisoning).
|
|
let (mut fresh, code, _) = timeout(BOUND, vanilla_cred_connect(door_addr, tcp_target))
|
|
.await
|
|
.expect("fresh CONNECT through the door");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
fresh.write_all(b"again").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
timeout(BOUND, fresh.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"again");
|
|
|
|
client_sock
|
|
.send_to(&rfc_datagram(udp_target, b"fresh"), udp_relay_addr)
|
|
.await
|
|
.expect("send fresh");
|
|
let (n, _) = timeout(BOUND, client_sock.recv_from(&mut rx))
|
|
.await
|
|
.expect("fresh udp echo")
|
|
.expect("recv");
|
|
assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"fresh");
|
|
}
|
|
|
|
/// The teardown matrix, door edition (the no-leak witness): the door
|
|
/// client's disconnect tears the association's channel down (the
|
|
/// channel count witnesses); a fresh association succeeds immediately
|
|
/// after (no connection poisoning).
|
|
#[tokio::test]
|
|
async fn door_client_disconnect_tears_the_association_down_no_leaks() {
|
|
init_trace();
|
|
let udp_target = echo_udp_server().await;
|
|
let topo = wire_consumer_side_with(
|
|
alksocks::local::tcp_dial(),
|
|
Some(alksocks::local::udp_egress()),
|
|
None,
|
|
Some(harness::consumer_identity()),
|
|
)
|
|
.await;
|
|
|
|
let relay = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay");
|
|
let relay_addr = relay.local_addr().expect("relay addr");
|
|
let door = UdpFrontDoor::builder().bind(relay_addr).build();
|
|
let serve = tokio::spawn({
|
|
let client = Arc::clone(&topo.consumer);
|
|
async move { door.serve_on(relay, client).await.expect("the door serves") }
|
|
});
|
|
|
|
// The association is live: a datagram round-trips and the channel
|
|
// exists.
|
|
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client socket");
|
|
client
|
|
.send_to(&rfc_datagram(udp_target, b"pre"), relay_addr)
|
|
.await
|
|
.expect("send");
|
|
let mut rx = vec![0u8; 65535];
|
|
let (n, _) = timeout(BOUND, client.recv_from(&mut rx))
|
|
.await
|
|
.expect("reply")
|
|
.expect("recv");
|
|
assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"pre");
|
|
assert!(
|
|
topo.consumer
|
|
.manager()
|
|
.channel_ids()
|
|
.iter()
|
|
.any(|&id| id != 0),
|
|
"the association rode a channel"
|
|
);
|
|
|
|
// The door client vanishes: the idle bound (the constructor knob)
|
|
// is the door's teardown trigger for a silent relay — with the
|
|
// socket gone, no datagrams arrive, the bound fires, the
|
|
// association ends (association lifetime = channel lifetime,
|
|
// ADR-003), and the door's task returns.
|
|
drop(client);
|
|
timeout(Duration::from_secs(35), serve)
|
|
.await
|
|
.expect("the door's task ended on the idle bound")
|
|
.expect("clean exit");
|
|
|
|
// No leak: the association's channel reaped.
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
assert!(
|
|
topo.consumer
|
|
.manager()
|
|
.channel_ids()
|
|
.iter()
|
|
.all(|&id| id == 0),
|
|
"the teardown reaped the association's channel: {:?}",
|
|
topo.consumer.manager().channel_ids()
|
|
);
|
|
|
|
// Fresh association succeeds immediately (no connection
|
|
// poisoning).
|
|
let relay2 = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay 2");
|
|
let relay2_addr = relay2.local_addr().expect("relay addr 2");
|
|
let door2 = UdpFrontDoor::builder().bind(relay2_addr).build();
|
|
let _serve2 = tokio::spawn({
|
|
let client = Arc::clone(&topo.consumer);
|
|
async move {
|
|
door2
|
|
.serve_on(relay2, client)
|
|
.await
|
|
.expect("the fresh door serves")
|
|
}
|
|
});
|
|
let client2 = UdpSocket::bind("127.0.0.1:0").await.expect("client 2");
|
|
client2
|
|
.send_to(&rfc_datagram(udp_target, b"fresh"), relay2_addr)
|
|
.await
|
|
.expect("send");
|
|
let (n, _) = timeout(BOUND, client2.recv_from(&mut rx))
|
|
.await
|
|
.expect("fresh reply")
|
|
.expect("recv");
|
|
assert_eq!(parse_rfc_datagram(&rx[..n]).1, b"fresh");
|
|
}
|
|
|
|
/// The composed story end to end (the ADR-008 composed variant set,
|
|
/// all three commands): the SOCKS5 producer serves
|
|
/// `composed::direct_egress` (ASSOCIATE — the lazy destination→tunnel
|
|
/// table) + `composed::bindfirst_accept` (BIND — the bind-first
|
|
/// listener) + a real TCP dial (CONNECT). The channels-native
|
|
/// sessions drive all three against the one registration; the
|
|
/// direct-op dial count witnesses the table's lazy shape.
|
|
#[tokio::test]
|
|
async fn composed_story_all_three_commands_on_one_registration() {
|
|
let suite = wire_composed_suite().await;
|
|
|
|
// CONNECT through the dial seam (a real TCP echo target —
|
|
// dialed by the producer's local dial).
|
|
let tcp_target = echo_tcp_server().await;
|
|
let mut session = timeout(
|
|
BOUND,
|
|
Socks5ChannelSession::connect(suite.topo.opener.as_ref(), TargetAddr::Ip(tcp_target)),
|
|
)
|
|
.await
|
|
.expect("connect within bound")
|
|
.expect("the CONNECT session");
|
|
let (mut read, mut write) = session.split().expect("pipe");
|
|
write.write_all(b"composed-connect").await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = [0u8; 16];
|
|
timeout(BOUND, read.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"composed-connect");
|
|
|
|
// ASSOCIATE through the composed egress (the direct op): one
|
|
// destination → one tunnel (the dial count witnesses the lazy
|
|
// open), the framed datagram round-trips through it.
|
|
let mut assoc = timeout(
|
|
BOUND,
|
|
AssociateSession::associate(suite.topo.opener.as_ref()),
|
|
)
|
|
.await
|
|
.expect("associate within bound")
|
|
.expect("the association");
|
|
let dest = TargetAddr::Ip(SocketAddr::from(([10, 9, 9, 9], 5353)));
|
|
assoc
|
|
.send_datagram(&dest, b"composed-udp")
|
|
.await
|
|
.expect("send");
|
|
let (src, payload) = timeout(BOUND, assoc.recv_datagram())
|
|
.await
|
|
.expect("recv within bound")
|
|
.expect("a datagram")
|
|
.expect("a datagram (not eof)");
|
|
assert_eq!(src, dest, "the echo reports the destination");
|
|
assert_eq!(&payload[..], b"composed-udp");
|
|
assert_eq!(
|
|
suite.dial_count.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"one direct-op dial for the one destination"
|
|
);
|
|
|
|
// BIND through the composed accept (the bind-first listener):
|
|
// reply#1 = the real bound; the queue accept; reply#2 = the real
|
|
// peer; the pass-through data plane.
|
|
let mut bind = timeout(
|
|
BOUND,
|
|
BindSession::bind(
|
|
suite.topo.opener.as_ref(),
|
|
("bind.test", 2121).to_target_addr().unwrap(),
|
|
),
|
|
)
|
|
.await
|
|
.expect("bind within bound")
|
|
.expect("the BIND session");
|
|
let bind_addr = bind.reply1().expect("reply#1").bind_addr;
|
|
assert_eq!(
|
|
bind_addr,
|
|
TargetAddr::Ip(SocketAddr::from(([127, 0, 0, 1], COMPOSED_BOUND_PORT))),
|
|
"reply#1 advertises the real OS-chosen bound"
|
|
);
|
|
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
|
let mut plane = timeout(BOUND, bind.await_inbound())
|
|
.await
|
|
.expect("reply#2")
|
|
.expect("await_inbound ok")
|
|
.expect("the plane");
|
|
assert_eq!(
|
|
plane.peer,
|
|
TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 1], COMPOSED_PEER_PORT))),
|
|
"reply#2 carries the forwarded op's real peer"
|
|
);
|
|
plane.write_all(b"composed-bind").await.expect("write");
|
|
plane.flush().await.expect("flush");
|
|
let mut bbuf = [0u8; 13];
|
|
timeout(BOUND, plane.read_exact(&mut bbuf))
|
|
.await
|
|
.expect("round trip")
|
|
.expect("read");
|
|
assert_eq!(&bbuf, b"composed-bind");
|
|
|
|
// All three sessions were live simultaneously; the teardowns end
|
|
// only their own sessions (mux/demux isolation) — exercised by
|
|
// the interleaving above. Drop + close.
|
|
drop(read);
|
|
drop(write);
|
|
drop(assoc);
|
|
drop(plane);
|
|
assert!(session.close().await, "the CONNECT channel reaped");
|
|
assert!(bind.close().await, "the BIND channel reaped");
|
|
}
|
|
|
|
/// Mixed assembly (the producer/consumer-roles-not-sides posture): one
|
|
/// producer registration serving channels-native sessions AND door
|
|
/// clients AND composed seams concurrently. The composed suite's
|
|
/// producer gains a door-shaped second consumer topology over the same
|
|
/// registration — the channels-native sessions and the door clients
|
|
/// interleave on the one registration (both shapes, one connection).
|
|
#[tokio::test]
|
|
async fn mixed_assembly_channels_native_and_door_clients_concurrent() {
|
|
init_trace();
|
|
let tcp_target = echo_tcp_server().await;
|
|
let udp_target = echo_udp_server().await;
|
|
|
|
let topo = wire_consumer_side_with_provider(
|
|
alksocks::local::tcp_dial(),
|
|
Some(alksocks::local::udp_egress()),
|
|
Some(alksocks::local::bind_accept([127, 0, 0, 1])),
|
|
Some(harness::consumer_identity()),
|
|
Arc::new(DoorTokenProvider {
|
|
bind_scoped: vec!["door-mixed".to_string()],
|
|
}),
|
|
)
|
|
.await;
|
|
let door_topo = wire_consumer_side_with_provider(
|
|
alksocks::local::tcp_dial(),
|
|
Some(alksocks::local::udp_egress()),
|
|
Some(alksocks::local::bind_accept([127, 0, 0, 1])),
|
|
Some(harness::consumer_identity()),
|
|
Arc::new(DoorTokenProvider {
|
|
bind_scoped: vec!["door-mixed".to_string()],
|
|
}),
|
|
)
|
|
.await;
|
|
let udp_door_topo = wire_consumer_side_with_provider(
|
|
alksocks::local::tcp_dial(),
|
|
Some(alksocks::local::udp_egress()),
|
|
Some(alksocks::local::bind_accept([127, 0, 0, 1])),
|
|
Some(harness::consumer_identity()),
|
|
Arc::new(DoorTokenProvider {
|
|
bind_scoped: vec!["door-mixed".to_string()],
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// The door fronts (the vanilla shape) on their own connections to
|
|
// the same registration (the cred-bearing door's TokenClient takes
|
|
// its client's call connection — the door-only contract; the UDP
|
|
// door's association rides a second connection).
|
|
let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let door_addr = tcp_listener.local_addr().expect("door addr");
|
|
let door = FrontDoor::builder()
|
|
.credentials(cred_table())
|
|
.bind(door_addr)
|
|
.build();
|
|
let _serve = tokio::spawn({
|
|
let client = Arc::clone(&door_topo.consumer);
|
|
async move {
|
|
door.serve_on(tcp_listener, client)
|
|
.await
|
|
.expect("the door serves")
|
|
}
|
|
});
|
|
let relay = UdpSocket::bind("127.0.0.1:0").await.expect("bind relay");
|
|
let relay_addr = relay.local_addr().expect("relay addr");
|
|
let udp_door = UdpFrontDoor::builder().bind(relay_addr).build();
|
|
let _udp_serve = tokio::spawn({
|
|
let client = Arc::clone(&udp_door_topo.consumer);
|
|
async move {
|
|
udp_door
|
|
.serve_on(relay, client)
|
|
.await
|
|
.expect("the udp door serves")
|
|
}
|
|
});
|
|
|
|
// The three shapes, concurrent on the one registration:
|
|
//
|
|
// 1. A channels-native CONNECT + ASSOCIATE + BIND session trio
|
|
// (the wrapper-aware shape).
|
|
// 2. A door-client CONNECT round-trip (the vanilla RFC 1929
|
|
// shape through the cred-bearing door).
|
|
// 3. A door-client ASSOCIATE datagram (the vanilla RFC-header'd
|
|
// shape through the UDP door).
|
|
//
|
|
// All six run interleaved (join_all), then each is verified
|
|
// mid-life of the others.
|
|
|
|
let native = async {
|
|
// CONNECT native.
|
|
let mut s =
|
|
Socks5ChannelSession::connect(topo.consumer.as_ref(), TargetAddr::Ip(tcp_target))
|
|
.await
|
|
.map_err(|e| format!("native connect: {e}"))?;
|
|
let (mut read, mut write) = s.split().ok_or_else(|| "no pipe".to_string())?;
|
|
write
|
|
.write_all(b"native-tcp")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
write.flush().await.map_err(|e| e.to_string())?;
|
|
let mut buf = [0u8; 10];
|
|
read.read_exact(&mut buf).await.map_err(|e| e.to_string())?;
|
|
if &buf != b"native-tcp" {
|
|
return Err("native connect echo mismatch".to_string());
|
|
}
|
|
drop(read);
|
|
drop(write);
|
|
s.close().await;
|
|
Ok(())
|
|
};
|
|
|
|
let native_udp = async {
|
|
let mut a = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.map_err(|e| format!("native associate: {e}"))?;
|
|
let target = TargetAddr::Ip(udp_target);
|
|
a.send_datagram(&target, b"native-udp")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let (src, payload) = a
|
|
.recv_datagram()
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| "eof before a datagram".to_string())?;
|
|
if src != target || &payload[..] != b"native-udp" {
|
|
return Err("native associate echo mismatch".to_string());
|
|
}
|
|
a.close().await;
|
|
Ok(())
|
|
};
|
|
|
|
let door_tcp = async {
|
|
let mut stream = timeout(BOUND, vanilla_with_creds(door_addr))
|
|
.await
|
|
.map_err(|e| format!("door connect: {e}"))?;
|
|
let mut req = vec![0x05, 0x01, 0x00, 0x01];
|
|
let SocketAddr::V4(v4) = tcp_target else {
|
|
panic!("v4")
|
|
};
|
|
req.extend_from_slice(&v4.ip().octets());
|
|
req.extend_from_slice(&v4.port().to_be_bytes());
|
|
stream.write_all(&req).await.map_err(|e| e.to_string())?;
|
|
let mut head = [0u8; 4];
|
|
timeout(BOUND, stream.read_exact(&mut head))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
if head[1] != consts::SOCKS5_REPLY_SUCCEEDED {
|
|
return Err(format!("door connect reply {}", head[1]));
|
|
}
|
|
let mut bnd = [0u8; 6];
|
|
timeout(BOUND, stream.read_exact(&mut bnd))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
stream
|
|
.write_all(b"door-tcp")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let mut buf = [0u8; 8];
|
|
timeout(BOUND, stream.read_exact(&mut buf))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
if &buf != b"door-tcp" {
|
|
return Err("door connect echo mismatch".to_string());
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
let door_udp = async {
|
|
let sock = UdpSocket::bind("127.0.0.1:0")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
sock.send_to(&rfc_datagram(udp_target, b"door-udp"), relay_addr)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let mut rx = vec![0u8; 65535];
|
|
let (n, _) = timeout(BOUND, sock.recv_from(&mut rx))
|
|
.await
|
|
.map_err(|e| format!("door udp recv: {e}"))?
|
|
.map_err(|e| e.to_string())?;
|
|
if parse_rfc_datagram(&rx[..n]).1 != b"door-udp" {
|
|
return Err("door udp echo mismatch".to_string());
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
let (r1, r2, r3, r4) = tokio::join!(native, native_udp, door_tcp, door_udp);
|
|
r1.expect("native connect");
|
|
r2.expect("native associate");
|
|
r3.expect("door connect");
|
|
r4.expect("door associate");
|
|
|
|
// The registration is healthy after the mixed burst: one more of
|
|
// each shape succeeds.
|
|
let (mut fresh, code, _) = timeout(BOUND, vanilla_cred_connect(door_addr, tcp_target))
|
|
.await
|
|
.expect("fresh door connect");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
fresh.write_all(b"again").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
timeout(BOUND, fresh.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"again");
|
|
|
|
let mut fresh_native =
|
|
Socks5ChannelSession::connect(topo.consumer.as_ref(), TargetAddr::Ip(tcp_target))
|
|
.await
|
|
.expect("fresh native connect");
|
|
let (mut read, mut write) = fresh_native.split().expect("pipe");
|
|
write.write_all(b"again2").await.expect("write");
|
|
let mut buf2 = [0u8; 6];
|
|
timeout(BOUND, read.read_exact(&mut buf2))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf2, b"again2");
|
|
drop(read);
|
|
drop(write);
|
|
fresh_native.close().await;
|
|
}
|
|
|
|
/// The mixed-composed variant of the mixed-assembly pin: channels-
|
|
/// native sessions AND composed seams AND door clients on one
|
|
/// registration — the composed suite's producer + a door consumer
|
|
/// topology, both shapes live on one connection (roles-not-sides: the
|
|
/// producer serves every shape through its seams; the connection
|
|
/// direction is independent of the service direction).
|
|
#[tokio::test]
|
|
async fn mixed_assembly_composed_seams_beside_channels_native_sessions() {
|
|
let suite = wire_composed_suite().await;
|
|
let tcp_target = echo_tcp_server().await;
|
|
|
|
// Channels-native CONNECT + composed BIND + composed ASSOCIATE,
|
|
// all opened before any is exercised (live simultaneously), then
|
|
// each exercised mid-life of the others; teardowns end only their
|
|
// own sessions.
|
|
let mut connect = timeout(
|
|
BOUND,
|
|
Socks5ChannelSession::connect(suite.topo.opener.as_ref(), TargetAddr::Ip(tcp_target)),
|
|
)
|
|
.await
|
|
.expect("connect")
|
|
.expect("the CONNECT session");
|
|
let mut assoc = timeout(
|
|
BOUND,
|
|
AssociateSession::associate(suite.topo.opener.as_ref()),
|
|
)
|
|
.await
|
|
.expect("associate")
|
|
.expect("the association");
|
|
let mut bind = timeout(
|
|
BOUND,
|
|
BindSession::bind(
|
|
suite.topo.opener.as_ref(),
|
|
("bind.test", 2121).to_target_addr().unwrap(),
|
|
),
|
|
)
|
|
.await
|
|
.expect("bind")
|
|
.expect("the BIND session");
|
|
|
|
// The BIND's quiet wait is live (the listener exists); complete it
|
|
// mid-life.
|
|
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
|
let mut plane = timeout(BOUND, bind.await_inbound())
|
|
.await
|
|
.expect("reply#2")
|
|
.expect("await_inbound ok")
|
|
.expect("the plane");
|
|
plane.write_all(b"mid").await.expect("write");
|
|
let mut bbuf = [0u8; 3];
|
|
timeout(BOUND, plane.read_exact(&mut bbuf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&bbuf, b"mid");
|
|
|
|
// The CONNECT + ASSOCIATE sessions still serve (no connection
|
|
// poisoning).
|
|
let (mut read, mut write) = connect.split().expect("pipe");
|
|
write.write_all(b"alive").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
timeout(BOUND, read.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"alive");
|
|
|
|
let dest = TargetAddr::Ip(SocketAddr::from(([10, 9, 9, 9], 5353)));
|
|
assoc.send_datagram(&dest, b"alive").await.expect("send");
|
|
let (src, payload) = timeout(BOUND, assoc.recv_datagram())
|
|
.await
|
|
.expect("recv")
|
|
.expect("a datagram")
|
|
.expect("a datagram (not eof)");
|
|
assert_eq!(src, dest);
|
|
assert_eq!(&payload[..], b"alive");
|
|
|
|
// Teardowns end only their own sessions; the registration serves
|
|
// a fresh CONNECT after.
|
|
drop(read);
|
|
drop(write);
|
|
drop(assoc);
|
|
drop(plane);
|
|
assert!(connect.close().await);
|
|
assert!(bind.close().await);
|
|
let mut fresh = timeout(
|
|
BOUND,
|
|
Socks5ChannelSession::connect(suite.topo.opener.as_ref(), TargetAddr::Ip(tcp_target)),
|
|
)
|
|
.await
|
|
.expect("fresh connect")
|
|
.expect("the fresh session");
|
|
let (mut read, mut write) = fresh.split().expect("pipe");
|
|
write.write_all(b"again").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
timeout(BOUND, read.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"again");
|
|
drop(read);
|
|
drop(write);
|
|
fresh.close().await;
|
|
|
|
// The handler-exit teardowns reap asynchronously (the open
|
|
// wrapper's post-handler teardown) — settle past the race, then
|
|
// probe (the composed suites' shape).
|
|
for _ in 0..50 {
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let ids = suite.topo.producer_manager.channel_ids();
|
|
if ids.iter().all(|&id| id == 0 || id % 2 == 1) {
|
|
break;
|
|
}
|
|
}
|
|
// No channel leaks (the composed suites' probe; the producer side
|
|
// may retain the adopted forwarded entries — the documented
|
|
// concession).
|
|
harness::assert_composed_channels(&suite.topo, &[0], &[]).await;
|
|
}
|
|
|
|
/// The producer-side local backend as the `-D` story's other half (the
|
|
/// one-registration-three-commands regression through the producer's
|
|
/// own front door): a vanilla NoAuth client drives CONNECT + BIND +
|
|
/// ASSOCIATE through one `ProducerDoor` against one producer
|
|
/// registration (the `local` provider set), all three live
|
|
/// simultaneously — one accepted connection per command (the RFC
|
|
/// conversation is one command per accepted socket, the vanilla
|
|
/// server's shape).
|
|
#[tokio::test]
|
|
async fn producer_door_one_registration_three_commands() {
|
|
init_trace();
|
|
let tcp_target = echo_tcp_server().await;
|
|
let udp_target = echo_udp_server().await;
|
|
|
|
let producer = alksocks::producer::Socks5Producer::builder()
|
|
.dial(alksocks::local::tcp_dial())
|
|
.egress(alksocks::local::udp_egress())
|
|
.accept(alksocks::local::bind_accept([127, 0, 0, 1]))
|
|
.build();
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let door_addr = listener.local_addr().expect("door addr");
|
|
let door = alksocks::local::ProducerDoor::builder()
|
|
.producer(producer)
|
|
.build();
|
|
let _guard = door.serve_on(listener).await;
|
|
|
|
// CONNECT (live).
|
|
let (mut connect_stream, code, _) =
|
|
timeout(BOUND, vanilla_no_auth_connect(door_addr, tcp_target))
|
|
.await
|
|
.expect("connect round trip");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
|
|
// BIND (live, mid-life of the CONNECT) — its own door connection.
|
|
let mut bind_stream = TcpStream::connect(door_addr).await.expect("door connect");
|
|
bind_stream
|
|
.write_all(&[0x05, 0x01, 0x00])
|
|
.await
|
|
.expect("greeting");
|
|
let mut chosen = [0u8; 2];
|
|
timeout(BOUND, bind_stream.read_exact(&mut chosen))
|
|
.await
|
|
.expect("method selection")
|
|
.expect("read");
|
|
assert_eq!(chosen, [0x05, 0x00]);
|
|
bind_stream
|
|
.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
|
.await
|
|
.expect("bind request");
|
|
let mut head1 = [0u8; 4];
|
|
timeout(BOUND, bind_stream.read_exact(&mut head1))
|
|
.await
|
|
.expect("bind reply#1")
|
|
.expect("read");
|
|
assert_eq!(head1[1], consts::SOCKS5_REPLY_SUCCEEDED);
|
|
let mut bnd = [0u8; 6];
|
|
timeout(BOUND, bind_stream.read_exact(&mut bnd))
|
|
.await
|
|
.expect("reply#1 BND.ADDR")
|
|
.expect("read");
|
|
let advertised = SocketAddr::from((
|
|
[bnd[0], bnd[1], bnd[2], bnd[3]],
|
|
u16::from_be_bytes([bnd[4], bnd[5]]),
|
|
));
|
|
let mut app = timeout(BOUND, TcpStream::connect(advertised))
|
|
.await
|
|
.expect("dial the advertised address")
|
|
.expect("app connected");
|
|
app.write_all(b"prod-bind").await.expect("app writes");
|
|
let mut head2 = [0u8; 4];
|
|
timeout(BOUND, bind_stream.read_exact(&mut head2))
|
|
.await
|
|
.expect("bind reply#2")
|
|
.expect("read");
|
|
assert_eq!(head2[1], consts::SOCKS5_REPLY_SUCCEEDED, "BIND reply#2");
|
|
let mut peer_addr = [0u8; 6];
|
|
timeout(BOUND, bind_stream.read_exact(&mut peer_addr))
|
|
.await
|
|
.expect("reply#2 DST.ADDR")
|
|
.expect("read");
|
|
assert_eq!(
|
|
u16::from_be_bytes([peer_addr[4], peer_addr[5]]),
|
|
app.local_addr().expect("app local").port(),
|
|
"reply#2 carries the real peer"
|
|
);
|
|
|
|
// ASSOCIATE (live, mid-life of both) — its own door connection:
|
|
// the sentinel reply + the in-band framed datagram through the
|
|
// real egress (the producer door path speaks the in-band wire —
|
|
// the ASSOCIATE datagram plane has no door-side RFC codec here;
|
|
// the vanilla client is wrapper-aware on this path).
|
|
let mut assoc_stream = TcpStream::connect(door_addr).await.expect("door connect");
|
|
assoc_stream
|
|
.write_all(&[0x05, 0x01, 0x00])
|
|
.await
|
|
.expect("greeting");
|
|
let mut chosen3 = [0u8; 2];
|
|
timeout(BOUND, assoc_stream.read_exact(&mut chosen3))
|
|
.await
|
|
.expect("method selection")
|
|
.expect("read");
|
|
assert_eq!(chosen3, [0x05, 0x00]);
|
|
assoc_stream
|
|
.write_all(&[0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
|
.await
|
|
.expect("associate request");
|
|
let mut head3 = [0u8; 4];
|
|
timeout(BOUND, assoc_stream.read_exact(&mut head3))
|
|
.await
|
|
.expect("associate reply")
|
|
.expect("read");
|
|
assert_eq!(
|
|
head3[1],
|
|
consts::SOCKS5_REPLY_SUCCEEDED,
|
|
"ASSOCIATE through the door"
|
|
);
|
|
let mut bnd3 = [0u8; 6];
|
|
timeout(BOUND, assoc_stream.read_exact(&mut bnd3))
|
|
.await
|
|
.expect("the sentinel BND.ADDR")
|
|
.expect("read");
|
|
assert_eq!(&bnd3, &[127, 0, 0, 1, 0, 0], "the sentinel");
|
|
|
|
// All three data planes, live simultaneously.
|
|
connect_stream.write_all(b"prod-tcp").await.expect("write");
|
|
let mut buf = [0u8; 8];
|
|
timeout(BOUND, connect_stream.read_exact(&mut buf))
|
|
.await
|
|
.expect("tcp echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"prod-tcp");
|
|
|
|
let mut app_buf = [0u8; 9];
|
|
timeout(BOUND, bind_stream.read_exact(&mut app_buf))
|
|
.await
|
|
.expect("bind data plane")
|
|
.expect("read");
|
|
assert_eq!(&app_buf, b"prod-bind");
|
|
|
|
let dst = TargetAddr::Ip(udp_target);
|
|
let frame = alksocks::wire::frame(&dst, b"prod-udp").expect("frame");
|
|
assoc_stream.write_all(&frame).await.expect("datagram");
|
|
let mut reply = vec![0u8; frame.len()];
|
|
timeout(BOUND, assoc_stream.read_exact(&mut reply))
|
|
.await
|
|
.expect("udp echo")
|
|
.expect("read");
|
|
let n = u16::from_be_bytes([reply[0], reply[1]]) as usize;
|
|
let inner = &reply[2..2 + n];
|
|
let atyp = inner[0];
|
|
let addr_len = match atyp {
|
|
0x01 => 1 + 4 + 2,
|
|
0x03 => 1 + 1 + inner[1] as usize + 2,
|
|
0x04 => 1 + 16 + 2,
|
|
other => panic!("unexpected ATYP {other}"),
|
|
};
|
|
assert_eq!(&inner[addr_len..], b"prod-udp", "the datagram echoed");
|
|
|
|
// The teardown: the client drops; the door stays serving (the
|
|
// loop continues — the per-accept isolation); a fresh session of
|
|
// every command succeeds (fresh CONNECT here; the door is
|
|
// healthy).
|
|
drop(connect_stream);
|
|
drop(app);
|
|
let (_, code, _) = timeout(BOUND, vanilla_no_auth_connect(door_addr, tcp_target))
|
|
.await
|
|
.expect("fresh connect after the teardown");
|
|
assert_eq!(
|
|
code,
|
|
consts::SOCKS5_REPLY_SUCCEEDED,
|
|
"no connection poisoning"
|
|
);
|
|
}
|
|
|
|
/// The `-D` story's vanilla-client-fidelity pin (tun2proxy-shaped): a
|
|
/// domain-form CONNECT through the cred-bearing door resolves at the
|
|
/// producer's seam (the ssh `-D` semantic, ADR-012) — `localhost` is
|
|
/// reachable as the echo target.
|
|
#[tokio::test]
|
|
async fn dash_d_domain_targets_resolve_at_the_producer_seam() {
|
|
init_trace();
|
|
let tcp_target = echo_tcp_server().await;
|
|
let topo = wire_consumer_side_with(
|
|
alksocks::local::tcp_dial(),
|
|
None,
|
|
None,
|
|
Some(harness::consumer_identity()),
|
|
)
|
|
.await;
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let door_addr = listener.local_addr().expect("door addr");
|
|
let door = FrontDoor::builder().bind(door_addr).build();
|
|
let _serve = tokio::spawn({
|
|
let client = Arc::clone(&topo.consumer);
|
|
async move {
|
|
door.serve_on(listener, client)
|
|
.await
|
|
.expect("the door serves")
|
|
}
|
|
});
|
|
|
|
// The NoAuth door is the raw pipe: the vanilla client's whole RFC
|
|
// conversation (domain-form CONNECT included) crosses it verbatim
|
|
// and resolves at the producer's dial seam.
|
|
let mut stream = TcpStream::connect(door_addr).await.expect("door connect");
|
|
stream
|
|
.write_all(&[0x05, 0x01, 0x00])
|
|
.await
|
|
.expect("greeting");
|
|
let mut chosen = [0u8; 2];
|
|
stream
|
|
.read_exact(&mut chosen)
|
|
.await
|
|
.expect("method selection");
|
|
assert_eq!(chosen, [0x05, 0x00]);
|
|
|
|
let domain = "localhost".to_owned();
|
|
let mut req = vec![0x05, 0x01, 0x00, 0x03, domain.len() as u8];
|
|
req.extend_from_slice(domain.as_bytes());
|
|
req.extend_from_slice(&tcp_target.port().to_be_bytes());
|
|
stream.write_all(&req).await.expect("request");
|
|
let mut head = [0u8; 4];
|
|
timeout(BOUND, stream.read_exact(&mut head))
|
|
.await
|
|
.expect("reply")
|
|
.expect("read");
|
|
assert_eq!(
|
|
head[1],
|
|
consts::SOCKS5_REPLY_SUCCEEDED,
|
|
"the domain resolved"
|
|
);
|
|
let mut bnd = [0u8; 6];
|
|
timeout(BOUND, stream.read_exact(&mut bnd))
|
|
.await
|
|
.expect("BND.ADDR")
|
|
.expect("read");
|
|
stream.write_all(b"dom").await.expect("write");
|
|
let mut buf = [0u8; 3];
|
|
timeout(BOUND, stream.read_exact(&mut buf))
|
|
.await
|
|
.expect("echo")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"dom");
|
|
}
|