- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
1033 lines
36 KiB
Rust
1033 lines
36 KiB
Rust
//! The cross-cutting integration suite: the whole protocol core's
|
|
//! behavior pinned in one place (the batch-1 closing task; the
|
|
//! architecture-sync check's test half).
|
|
//!
|
|
//! The per-command tasks own their verticals (tests/consumer-*.rs and
|
|
//! the `src/` unit suites); this suite owns the cross-cutting
|
|
//! invariants:
|
|
//!
|
|
//! - **One registration, all three commands** (the POC #2/#5
|
|
//! regression guard, complete): CONNECT + ASSOCIATE + BIND all
|
|
//! served on the same `channels/socks5/sub` op, interleaved live
|
|
//! sessions on one channels connection.
|
|
//! - **Concurrency**: mixed concurrent sessions over one channels
|
|
//! connection (the mux/demux under load; the POCs tested
|
|
//! sequentially), pinned in both allocation directions (the raw
|
|
//! topology where the producer's serving side allocates the channel
|
|
//! ids, and the consumer-side topology where the consumer does).
|
|
//! - **The teardown matrix** (wire.md §Sentinels and lifecycle, the
|
|
//! normative source): a client half-close ends a CONNECT data plane
|
|
//! cleanly; ASSOCIATE EOF ends the association; a BIND client-gone
|
|
//! teardown releases the accept; a fresh session succeeds
|
|
//! immediately after each teardown (no connection poisoning).
|
|
//! - **The identity/scope matrix** (ADR-006, end to end):
|
|
//! `socks5:open`-only (CONNECT + ASSOCIATE serve, BIND refused 0x02
|
|
//! at the command gate), `socks5:bind`-only (fail-closed at the
|
|
//! open gate, all three commands; ADR-006's open gate requires
|
|
//! `socks5:open` unconditionally and the two grants are never
|
|
//! implied), both scopes (everything, granted explicitly).
|
|
//! - **The readiness contract** re-pinned under concurrent load (the
|
|
//! producer-associate task owns the unit-level test): a hot
|
|
//! `Ok(None)` egress across concurrent associations must not starve
|
|
//! the datagram stage's channel branch.
|
|
|
|
mod harness;
|
|
|
|
use std::future::Future;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
|
|
use alksocks::consumer::{AssociateSession, BindSession, Socks5ChannelSession};
|
|
use alksocks::egress::{Egress, EgressError, EgressFactory};
|
|
use alksocks::error::SocksSessionError;
|
|
use alksocks::params::Socks5Params;
|
|
use alksocks::rfc1928::consts;
|
|
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
|
|
use alksocks::rfc1928::Socks5Command;
|
|
|
|
use harness::{
|
|
bind_identity, both_scopes_identity, consumer_identity, echo_accept, echo_dial, echo_egress,
|
|
wire_consumer_side_with, wire_with, ConsumerSideTopology, RawRfcClient, RegisterMode, Topology,
|
|
ECHO_BIND_PORT,
|
|
};
|
|
|
|
const TIMEOUT_SECS: u64 = 10;
|
|
|
|
type RawPump = tokio::task::JoinHandle<(u64, u64)>;
|
|
type RawClient = RawRfcClient<tokio::io::DuplexStream>;
|
|
type RoundTrip<'a> = std::pin::Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
|
|
|
|
async fn within<T>(fut: impl Future<Output = T>) -> T {
|
|
tokio::time::timeout(std::time::Duration::from_secs(TIMEOUT_SECS), fut)
|
|
.await
|
|
.expect("operation timed out")
|
|
}
|
|
|
|
// --- Topology builders ------------------------------------------------------
|
|
|
|
/// The full raw topology: all three seams wired, the both-scopes
|
|
/// identity (everything serves).
|
|
async fn full_raw_topology() -> Topology {
|
|
wire_with(
|
|
RegisterMode::Producer {
|
|
dial: echo_dial(),
|
|
egress: Some(echo_egress()),
|
|
accept: Some(echo_accept()),
|
|
},
|
|
Some(both_scopes_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await
|
|
}
|
|
|
|
// --- Raw-session helpers (the harness's raw-RFC pattern) ---------------------
|
|
|
|
/// Open a socks5 channel + adopt it + pump the raw RFC client through
|
|
/// it (the smoke test's opening shape).
|
|
async fn open_adopt_pump(topo: &Topology) -> (RawClient, RawPump) {
|
|
let response = topo
|
|
.consumer_call
|
|
.call(alksocks::OP_SOCKS5_OPEN, Socks5Params::as_json())
|
|
.await;
|
|
let out = response.result.expect("the socks5 channel opens");
|
|
let channel_id = out
|
|
.get("channel_id")
|
|
.and_then(|v| v.as_u64())
|
|
.expect("the reply carries channel_id") as u32;
|
|
let (send, recv) = topo
|
|
.consumer_manager
|
|
.adopt_channel(channel_id, alksocks::SOCKS5_ALPN, None)
|
|
.await
|
|
.expect("adopt the producer-allocated id");
|
|
let (stream_peer, stream_self) = tokio::io::duplex(64 * 1024);
|
|
let pump = tokio::spawn(alkcall::channels::pump::pump_bidi(
|
|
stream_self,
|
|
Box::new(recv) as Box<dyn AsyncRead + Send + Unpin>,
|
|
Box::new(send) as Box<dyn AsyncWrite + Send + Unpin>,
|
|
));
|
|
(RawRfcClient::new(stream_peer), pump)
|
|
}
|
|
|
|
/// The ASSOCIATE conversation over a fresh channel: greet → request →
|
|
/// the sentinel `127.0.0.1:0` reply (ADR-003).
|
|
async fn raw_associate(topo: &Topology) -> (RawClient, RawPump) {
|
|
let (mut raw, pump) = open_adopt_pump(topo).await;
|
|
raw.greet().await.expect("greet");
|
|
raw.request(
|
|
Socks5Command::UDPAssociate,
|
|
&TargetAddr::Domain("relay.test".into(), 1080),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code, addr) = raw.read_reply().await.expect("reply read");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED, "the associate reply");
|
|
assert_eq!(
|
|
(addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]),
|
|
(127, 0, 0, 1, 0, 0),
|
|
"the reply carries the sentinel BND.ADDR"
|
|
);
|
|
(raw, pump)
|
|
}
|
|
|
|
/// The BIND conversation over a fresh channel through the echo
|
|
/// accept: greet → request → reply#1 (the advertised port) → reply#2
|
|
/// (the accepted peer, the harness's fixed stand-in).
|
|
async fn raw_bind(topo: &Topology, host: &str) -> (RawClient, RawPump) {
|
|
let (mut raw, pump) = open_adopt_pump(topo).await;
|
|
raw.greet().await.expect("greet");
|
|
raw.request(
|
|
Socks5Command::TCPBind,
|
|
&TargetAddr::Domain(host.into(), ECHO_BIND_PORT),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code1, addr1) = raw.read_reply().await.expect("reply#1");
|
|
assert_eq!(code1, consts::SOCKS5_REPLY_SUCCEEDED, "reply#1 success");
|
|
assert_eq!(
|
|
u16::from_be_bytes([addr1[addr1.len() - 2], addr1[addr1.len() - 1]]),
|
|
ECHO_BIND_PORT,
|
|
"reply#1 carries the factory's advertised port"
|
|
);
|
|
let (code2, addr2) = raw.read_reply().await.expect("reply#2");
|
|
assert_eq!(code2, consts::SOCKS5_REPLY_SUCCEEDED, "reply#2 success");
|
|
let peer_port = u16::from_be_bytes([addr2[addr2.len() - 2], addr2[addr2.len() - 1]]);
|
|
assert_eq!(peer_port, 44444, "reply#2 carries the accepted peer");
|
|
(raw, pump)
|
|
}
|
|
|
|
async fn connect_round_trip(topo: &Topology, host: String, tag: String) -> Result<(), String> {
|
|
let (mut raw, _pump) = open_adopt_pump(topo).await;
|
|
let code = raw
|
|
.connect(&TargetAddr::Domain(host, 443))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if code != consts::SOCKS5_REPLY_SUCCEEDED {
|
|
return Err(format!("reply code {code}"));
|
|
}
|
|
raw.stream
|
|
.write_all(tag.as_bytes())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let mut buf = vec![0u8; tag.len()];
|
|
raw.stream
|
|
.read_exact(&mut buf)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if buf != tag.as_bytes() {
|
|
return Err("echo mismatch".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A raw ASSOCIATE round-trip: sentinel reply + one datagram echoed
|
|
/// in order.
|
|
async fn associate_round_trip(
|
|
topo: &Topology,
|
|
octet: u8,
|
|
tag: &'static [u8],
|
|
) -> Result<(), String> {
|
|
let (mut raw, _pump) = raw_associate(topo).await;
|
|
let target = TargetAddr::Ip(SocketAddr::from(([10, 0, 0, octet], 53)));
|
|
raw.stream
|
|
.write_all(&udp_frame(&target, tag))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let mut buf = vec![0u8; 2 + 7 + tag.len()];
|
|
raw.stream
|
|
.read_exact(&mut buf)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let (src, payload) = udp_unframe(&buf);
|
|
if src != target {
|
|
return Err("echo source mismatch".into());
|
|
}
|
|
if &payload[..] != tag {
|
|
return Err("echo payload mismatch".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A raw BIND round-trip: reply#1 + reply#2 + one pass-through echo
|
|
/// exchange on the data plane.
|
|
async fn bind_round_trip(
|
|
topo: &Topology,
|
|
host: impl ToString,
|
|
tag: &'static [u8],
|
|
) -> Result<(), String> {
|
|
let (mut raw, _pump) = raw_bind(topo, &host.to_string()).await;
|
|
raw.stream.write_all(tag).await.map_err(|e| e.to_string())?;
|
|
let mut buf = vec![0u8; tag.len()];
|
|
raw.stream
|
|
.read_exact(&mut buf)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if buf != tag {
|
|
return Err("echo mismatch".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A raw CONNECT round-trip boxed for mixed concurrent joining.
|
|
fn tcp_fut<'a>(topo: &'a Topology, host: String, tag: String) -> RoundTrip<'a> {
|
|
Box::pin(connect_round_trip(topo, host, tag))
|
|
}
|
|
|
|
/// A raw ASSOCIATE round-trip boxed for mixed concurrent joining.
|
|
fn udp_fut<'a>(topo: &'a Topology, octet: u8, tag: &'static [u8]) -> RoundTrip<'a> {
|
|
Box::pin(associate_round_trip(topo, octet, tag))
|
|
}
|
|
|
|
/// A raw BIND round-trip boxed for mixed concurrent joining.
|
|
fn bind_fut<'a>(topo: &'a Topology, host: String, tag: &'static [u8]) -> RoundTrip<'a> {
|
|
Box::pin(bind_round_trip(topo, host, tag))
|
|
}
|
|
|
|
// --- The ASSOCIATE wire-frame helpers (the common module's shapes) -----------
|
|
|
|
use harness::{parked_accept_factory as parked_accept, udp_frame, udp_unframe};
|
|
|
|
// --- Seam stand-ins ----------------------------------------------------------
|
|
|
|
// The parked accept factory rides the common module (imported as
|
|
// `parked_accept` above, the deterministic quiet-wait probe; the
|
|
// advertised bind address is the common module's 2121).
|
|
|
|
/// The hot egress (the readiness contract's violator, the
|
|
/// producer-associate unit test's shape): `next_reply` drains one
|
|
/// queued reply if any, then returns an instantly-ready `Ok(None)`
|
|
/// instead of parking. The stage must tolerate this under load: a
|
|
/// biased select starved into livelock would hang every session here.
|
|
struct HotNoneEgress {
|
|
echo: tokio::sync::mpsc::UnboundedSender<bytes::Bytes>,
|
|
replies: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<bytes::Bytes>>,
|
|
}
|
|
|
|
impl Egress for HotNoneEgress {
|
|
fn send(
|
|
&mut self,
|
|
addr: TargetAddr,
|
|
payload: bytes::Bytes,
|
|
) -> futures::future::BoxFuture<'_, Result<(), EgressError>> {
|
|
Box::pin(async move {
|
|
let mut inner = addr
|
|
.to_be_bytes()
|
|
.map_err(|e| EgressError::General(format!("addr encode: {e}")))?;
|
|
inner.extend_from_slice(&payload);
|
|
self.echo
|
|
.send(bytes::Bytes::from(inner))
|
|
.map_err(|_| EgressError::General("egress closed".into()))
|
|
})
|
|
}
|
|
|
|
fn next_reply(
|
|
&mut self,
|
|
) -> futures::future::BoxFuture<'_, Result<Option<bytes::Bytes>, EgressError>> {
|
|
Box::pin(async move {
|
|
match self.replies.lock().await.try_recv() {
|
|
Ok(inner) => Ok(Some(inner)),
|
|
Err(_) => Ok(None),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
fn hot_none_egress() -> EgressFactory {
|
|
Arc::new(|| {
|
|
Box::pin(async {
|
|
let (echo, replies) = tokio::sync::mpsc::unbounded_channel();
|
|
Ok(Box::new(HotNoneEgress {
|
|
echo,
|
|
replies: tokio::sync::Mutex::new(replies),
|
|
}) as Box<dyn Egress + Send>)
|
|
})
|
|
})
|
|
}
|
|
|
|
// --- The consumer-session probes ----------------------------------------------
|
|
|
|
/// The no-leaked-channels probe (the per-command suites' shape): both
|
|
/// managers hold only channel 0; the producer's entry reaps by its
|
|
/// open-op wrapper; the probe polls past the reap race.
|
|
async fn no_leaked_channels(topo: &ConsumerSideTopology) {
|
|
let deadline = std::time::Duration::from_secs(5);
|
|
let consumer_ids = topo.consumer.manager().channel_ids();
|
|
assert!(
|
|
consumer_ids.iter().all(|&id| id == 0),
|
|
"consumer leaked channel entries: {consumer_ids:?}"
|
|
);
|
|
tokio::time::timeout(deadline, async {
|
|
loop {
|
|
let producer_ids = topo.producer_manager.channel_ids();
|
|
if producer_ids.iter().all(|&id| id == 0) {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("the producer's handler-exit teardown reaps the entry");
|
|
}
|
|
|
|
/// A `FORBIDDEN`-carrying open failure (the fail-closed probe's
|
|
/// expectation shape).
|
|
fn expect_forbidden(err: &SocksSessionError) -> bool {
|
|
match err {
|
|
SocksSessionError::Open(open) => match open.open_ref() {
|
|
alkcall::channels::client::ChannelOpenError::CallFailed { error } => {
|
|
error.code == "FORBIDDEN"
|
|
}
|
|
_ => false,
|
|
},
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// A consumer CONNECT round-trip, result-shaped for concurrent
|
|
/// joining.
|
|
async fn consumer_connect_round_trip(
|
|
topo: &ConsumerSideTopology,
|
|
host: String,
|
|
tag: String,
|
|
) -> Result<(), String> {
|
|
let mut session = Socks5ChannelSession::connect(
|
|
topo.consumer.as_ref(),
|
|
(host.as_str(), 443u16).to_target_addr().unwrap(),
|
|
)
|
|
.await
|
|
.map_err(|e| format!("connect: {e}"))?;
|
|
session
|
|
.split()
|
|
.expect("pipe")
|
|
.1
|
|
.write_all(tag.as_bytes())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let (mut read, _) = session.split().expect("pipe");
|
|
let mut buf = vec![0u8; tag.len()];
|
|
read.read_exact(&mut buf).await.map_err(|e| e.to_string())?;
|
|
if buf != tag.as_bytes() {
|
|
return Err("echo mismatch".into());
|
|
}
|
|
session.close().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// A consumer ASSOCIATE round-trip: one datagram echoed in order on
|
|
/// the framed stage.
|
|
async fn consumer_associate_round_trip(
|
|
topo: &ConsumerSideTopology,
|
|
octet: u8,
|
|
tag: &'static [u8],
|
|
) -> Result<(), String> {
|
|
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
|
.await
|
|
.map_err(|e| format!("associate: {e}"))?;
|
|
let target = TargetAddr::Ip(SocketAddr::from(([10, 0, 1, octet], 53)));
|
|
assoc
|
|
.send_datagram(&target, tag)
|
|
.await
|
|
.map_err(|e| format!("send: {e}"))?;
|
|
let (src, payload) = within(assoc.recv_datagram())
|
|
.await
|
|
.map_err(|e| format!("recv: {e}"))?
|
|
.expect("a datagram (not eof)");
|
|
if src != target {
|
|
return Err("echo source mismatch".into());
|
|
}
|
|
if &payload[..] != tag {
|
|
return Err("echo payload mismatch".into());
|
|
}
|
|
assoc.close().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// A consumer CONNECT round-trip boxed for mixed concurrent joining.
|
|
fn consumer_tcp_fut<'a>(
|
|
topo: &'a ConsumerSideTopology,
|
|
host: String,
|
|
tag: String,
|
|
) -> RoundTrip<'a> {
|
|
Box::pin(consumer_connect_round_trip(topo, host, tag))
|
|
}
|
|
|
|
/// A consumer ASSOCIATE round-trip boxed for mixed concurrent joining.
|
|
fn consumer_udp_fut<'a>(
|
|
topo: &'a ConsumerSideTopology,
|
|
octet: u8,
|
|
tag: &'static [u8],
|
|
) -> RoundTrip<'a> {
|
|
Box::pin(consumer_associate_round_trip(topo, octet, tag))
|
|
}
|
|
|
|
/// A consumer BIND round-trip boxed for mixed concurrent joining.
|
|
fn consumer_bind_fut<'a>(
|
|
topo: &'a ConsumerSideTopology,
|
|
host: String,
|
|
tag: &'static [u8],
|
|
) -> RoundTrip<'a> {
|
|
Box::pin(consumer_bind_round_trip(topo, host, tag))
|
|
}
|
|
|
|
/// A consumer BIND round-trip: reply#1 + reply#2 + one echo exchange
|
|
/// on the pass-through plane.
|
|
async fn consumer_bind_round_trip(
|
|
topo: &ConsumerSideTopology,
|
|
host: String,
|
|
tag: &'static [u8],
|
|
) -> Result<(), String> {
|
|
let mut session = BindSession::bind(
|
|
topo.consumer.as_ref(),
|
|
(host.as_str(), ECHO_BIND_PORT).to_target_addr().unwrap(),
|
|
)
|
|
.await
|
|
.map_err(|e| format!("bind: {e}"))?;
|
|
let mut plane = session
|
|
.await_inbound()
|
|
.await
|
|
.map_err(|e| format!("reply#2: {e}"))?
|
|
.expect("the data plane");
|
|
plane.write_all(tag).await.map_err(|e| e.to_string())?;
|
|
plane.flush().await.map_err(|e| e.to_string())?;
|
|
let mut buf = vec![0u8; tag.len()];
|
|
within(plane.read_exact(&mut buf))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if buf != tag {
|
|
return Err("echo mismatch".into());
|
|
}
|
|
drop(plane);
|
|
session.close().await;
|
|
Ok(())
|
|
}
|
|
|
|
// --- The cross-cutting suite ---------------------------------------------------
|
|
|
|
/// One registration, all three commands, interleaved live sessions on
|
|
/// one channels connection (the POC #2/#5 regression guard,
|
|
/// complete): all three sessions are opened back to back and are live
|
|
/// simultaneously; each is exercised mid-life of the others; a
|
|
/// teardown ends only its own session (the others keep serving, the
|
|
/// mux/demux isolation).
|
|
#[tokio::test]
|
|
async fn one_registration_all_three_commands_interleaved() {
|
|
let topo = full_raw_topology().await;
|
|
|
|
// Three interleaved opens on the one op; all three sessions live
|
|
// at once (CONNECT, ASSOCIATE, BIND: fresh channel per session,
|
|
// convention 10).
|
|
let (mut connect_raw, connect_pump) = open_adopt_pump(&topo).await;
|
|
let (mut assoc_raw, assoc_pump) = raw_associate(&topo).await;
|
|
let (mut bind_raw, bind_pump) = raw_bind(&topo, "bind.test").await;
|
|
|
|
// The CONNECT session's RFC handshake (the other two helpers ran
|
|
// theirs on their own channels).
|
|
connect_raw.greet().await.expect("greet");
|
|
connect_raw
|
|
.request(
|
|
Socks5Command::TCPConnect,
|
|
&TargetAddr::Domain("interleaved.test".into(), 443),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code, _addr) = connect_raw.read_reply().await.expect("reply read");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
|
|
// Exercise each mid-life of the others.
|
|
connect_raw.stream.write_all(b"tcp1").await.expect("write");
|
|
let mut buf = [0u8; 4];
|
|
connect_raw.stream.read_exact(&mut buf).await.expect("echo");
|
|
assert_eq!(&buf, b"tcp1");
|
|
|
|
let udp_target = TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 1], 53)));
|
|
assoc_raw
|
|
.stream
|
|
.write_all(&udp_frame(&udp_target, b"udp1"))
|
|
.await
|
|
.expect("datagram");
|
|
let mut frame_buf = vec![0u8; 2 + 7 + 4];
|
|
assoc_raw
|
|
.stream
|
|
.read_exact(&mut frame_buf)
|
|
.await
|
|
.expect("frame");
|
|
let (_, payload) = udp_unframe(&frame_buf);
|
|
assert_eq!(&payload[..], b"udp1");
|
|
|
|
bind_raw.stream.write_all(b"bin1").await.expect("write");
|
|
bind_raw.stream.read_exact(&mut buf).await.expect("echo");
|
|
assert_eq!(&buf, b"bin1");
|
|
|
|
// A teardown ends only its own session.
|
|
drop(connect_raw);
|
|
within(connect_pump).await.expect("the CONNECT pump ends");
|
|
|
|
// ASSOCIATE + BIND still serve (no connection poisoning).
|
|
assoc_raw
|
|
.stream
|
|
.write_all(&udp_frame(&udp_target, b"still"))
|
|
.await
|
|
.expect("datagram");
|
|
let mut frame_buf = vec![0u8; 2 + 7 + 5];
|
|
assoc_raw
|
|
.stream
|
|
.read_exact(&mut frame_buf)
|
|
.await
|
|
.expect("frame");
|
|
let (_, payload) = udp_unframe(&frame_buf);
|
|
assert_eq!(&payload[..], b"still");
|
|
|
|
bind_raw.stream.write_all(b"alive").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
bind_raw.stream.read_exact(&mut buf).await.expect("echo");
|
|
assert_eq!(&buf, b"alive");
|
|
|
|
drop(assoc_raw);
|
|
drop(bind_raw);
|
|
within(assoc_pump).await.expect("associate pump");
|
|
within(bind_pump).await.expect("bind pump");
|
|
}
|
|
|
|
/// Concurrency over one channels connection (the mux/demux under
|
|
/// load): nine concurrent sessions of mixed command types, three
|
|
/// CONNECTs, three ASSOCIATEs, three BINDs, interleaved by the
|
|
/// executor, all riding the one registration. The producer's serving
|
|
/// side allocates the channel ids (the raw topology's direction).
|
|
#[tokio::test]
|
|
async fn concurrent_mixed_sessions_over_one_connection() {
|
|
let topo = full_raw_topology().await;
|
|
|
|
const UDP_TAGS: [&[u8]; 3] = [b"udp-zero0", b"udp-one!1", b"udp-two!2"];
|
|
const BIND_TAGS: [&[u8]; 3] = [b"bind-z0", b"bind-o1", b"bind-t2"];
|
|
let mut futs: Vec<RoundTrip> = Vec::new();
|
|
for i in 0..3usize {
|
|
futs.push(tcp_fut(&topo, format!("c{i}.test"), format!("tcp{i}")));
|
|
futs.push(udp_fut(&topo, 10 + i as u8, UDP_TAGS[i]));
|
|
futs.push(bind_fut(&topo, format!("b{i}.test"), BIND_TAGS[i]));
|
|
}
|
|
let results = futures::future::join_all(futs).await;
|
|
for (i, result) in results.into_iter().enumerate() {
|
|
result.unwrap_or_else(|e| panic!("concurrent session {i} failed: {e}"));
|
|
}
|
|
|
|
// The connection is healthy after the burst: one more of each.
|
|
connect_round_trip(&topo, "after.test".into(), "again".into())
|
|
.await
|
|
.expect("fresh connect after the load");
|
|
associate_round_trip(&topo, 20, b"again")
|
|
.await
|
|
.expect("fresh associate after the load");
|
|
bind_round_trip(&topo, "after.test", b"again")
|
|
.await
|
|
.expect("fresh bind after the load");
|
|
}
|
|
|
|
/// Concurrency through the typed consumer sessions over one channels
|
|
/// connection: six concurrent sessions (two of each command) driven
|
|
/// by the real session types. The consumer dials here (the
|
|
/// consumer-side topology), so the consumer's side allocates the
|
|
/// channel ids (the mux/demux pinned in the other allocation
|
|
/// direction. No channel entry leaks on either side.
|
|
#[tokio::test]
|
|
async fn concurrent_consumer_sessions_over_one_connection() {
|
|
let topo = wire_consumer_side_with(
|
|
echo_dial(),
|
|
Some(echo_egress()),
|
|
Some(echo_accept()),
|
|
Some(both_scopes_identity()),
|
|
)
|
|
.await;
|
|
|
|
const UDP_TAGS: [&[u8]; 2] = [b"cu-zero00", b"cu-one!!1"];
|
|
const BIND_TAGS: [&[u8]; 2] = [b"cb-zero", b"cb-one!"];
|
|
let mut futs: Vec<RoundTrip> = Vec::new();
|
|
for i in 0..2usize {
|
|
futs.push(consumer_tcp_fut(
|
|
&topo,
|
|
format!("cc{i}.test"),
|
|
format!("t{i}"),
|
|
));
|
|
futs.push(consumer_udp_fut(&topo, 30 + i as u8, UDP_TAGS[i]));
|
|
futs.push(consumer_bind_fut(
|
|
&topo,
|
|
format!("cb{i}.test"),
|
|
BIND_TAGS[i],
|
|
));
|
|
}
|
|
let results = futures::future::join_all(futs).await;
|
|
for (i, result) in results.into_iter().enumerate() {
|
|
result.unwrap_or_else(|e| panic!("consumer session {i} failed: {e}"));
|
|
}
|
|
|
|
no_leaked_channels(&topo).await;
|
|
}
|
|
|
|
/// The teardown matrix, CONNECT row (wire.md §Sentinels and
|
|
/// lifecycle): a client half-close ends the data plane cleanly (the
|
|
/// producer's pump exits) and a fresh session of every command
|
|
/// succeeds immediately after (no connection poisoning).
|
|
#[tokio::test]
|
|
async fn teardown_connect_half_close_then_every_command_serves_fresh() {
|
|
let topo = full_raw_topology().await;
|
|
|
|
let (mut raw, pump) = open_adopt_pump(&topo).await;
|
|
let code = raw
|
|
.connect(&TargetAddr::Domain("drop.test".into(), 443))
|
|
.await
|
|
.expect("connect");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
raw.stream.write_all(b"pre").await.expect("write");
|
|
let mut buf = [0u8; 3];
|
|
raw.stream.read_exact(&mut buf).await.expect("echo");
|
|
assert_eq!(&buf, b"pre");
|
|
|
|
// The client half-close: the producer's pump exits (the data
|
|
// plane is over; the connection stays healthy).
|
|
drop(raw);
|
|
within(pump)
|
|
.await
|
|
.expect("the pump ends on the client half-close");
|
|
|
|
// Fresh sessions of every command succeed immediately.
|
|
let (mut raw, _pump) = open_adopt_pump(&topo).await;
|
|
let code = raw
|
|
.connect(&TargetAddr::Domain("fresh.test".into(), 443))
|
|
.await
|
|
.expect("fresh connect");
|
|
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
raw.stream.write_all(b"again").await.expect("write");
|
|
let mut buf = [0u8; 5];
|
|
raw.stream.read_exact(&mut buf).await.expect("echo");
|
|
assert_eq!(&buf, b"again");
|
|
|
|
let (mut assoc, _pump) = raw_associate(&topo).await;
|
|
let target = TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 2], 53)));
|
|
assoc
|
|
.stream
|
|
.write_all(&udp_frame(&target, b"fresh"))
|
|
.await
|
|
.expect("datagram");
|
|
let mut frame_buf = vec![0u8; 2 + 7 + 5];
|
|
assoc
|
|
.stream
|
|
.read_exact(&mut frame_buf)
|
|
.await
|
|
.expect("frame");
|
|
let (_, payload) = udp_unframe(&frame_buf);
|
|
assert_eq!(&payload[..], b"fresh");
|
|
|
|
bind_round_trip(&topo, "fresh.test", b"fresh")
|
|
.await
|
|
.expect("fresh bind");
|
|
}
|
|
|
|
/// The teardown matrix, ASSOCIATE row: channel EOF ends the
|
|
/// association (the association lifetime = channel lifetime) and
|
|
/// fresh sessions of every command succeed immediately after.
|
|
#[tokio::test]
|
|
async fn teardown_associate_eof_then_every_command_serves_fresh() {
|
|
let topo = full_raw_topology().await;
|
|
|
|
let (mut assoc, pump) = raw_associate(&topo).await;
|
|
let target = TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 3], 53)));
|
|
assoc
|
|
.stream
|
|
.write_all(&udp_frame(&target, b"pre"))
|
|
.await
|
|
.expect("datagram");
|
|
let mut frame_buf = vec![0u8; 2 + 7 + 3];
|
|
assoc
|
|
.stream
|
|
.read_exact(&mut frame_buf)
|
|
.await
|
|
.expect("frame");
|
|
let (_, payload) = udp_unframe(&frame_buf);
|
|
assert_eq!(&payload[..], b"pre");
|
|
|
|
// Channel EOF ends the association (the clean exit).
|
|
drop(assoc);
|
|
within(pump).await.expect("the stage ends on channel EOF");
|
|
|
|
// Fresh sessions of every command succeed immediately.
|
|
let (mut assoc, _pump) = raw_associate(&topo).await;
|
|
assoc
|
|
.stream
|
|
.write_all(&udp_frame(&target, b"fresh"))
|
|
.await
|
|
.expect("datagram");
|
|
let mut frame_buf = vec![0u8; 2 + 7 + 5];
|
|
assoc
|
|
.stream
|
|
.read_exact(&mut frame_buf)
|
|
.await
|
|
.expect("frame");
|
|
let (_, payload) = udp_unframe(&frame_buf);
|
|
assert_eq!(&payload[..], b"fresh");
|
|
|
|
connect_round_trip(&topo, "fresh.test".into(), "again".into())
|
|
.await
|
|
.expect("fresh connect");
|
|
|
|
bind_round_trip(&topo, "fresh.test", b"fresh")
|
|
.await
|
|
.expect("fresh bind");
|
|
}
|
|
|
|
/// The teardown matrix, BIND row: the client vanishing in the quiet
|
|
/// wait releases the accept (the parked accept future drops with the
|
|
/// `select!` scope: the client-gone teardown, ADR-004/ADR-007) and
|
|
/// fresh sessions succeed immediately after.
|
|
#[tokio::test]
|
|
async fn teardown_bind_client_gone_releases_the_accept_then_serves_fresh() {
|
|
let topo = wire_with(
|
|
RegisterMode::Producer {
|
|
dial: echo_dial(),
|
|
egress: Some(echo_egress()),
|
|
accept: Some(parked_accept()),
|
|
},
|
|
Some(both_scopes_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
// Reply#1 lands (the parked factory resolves); the quiet wait is
|
|
// then the session's only live phase.
|
|
let (mut raw, pump) = open_adopt_pump(&topo).await;
|
|
raw.greet().await.expect("greet");
|
|
raw.request(
|
|
Socks5Command::TCPBind,
|
|
&TargetAddr::Domain("gone.test".into(), ECHO_BIND_PORT),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code1, _addr) = raw.read_reply().await.expect("reply#1");
|
|
assert_eq!(code1, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
|
|
// The client vanishes mid-quiet-wait: the session ends cleanly
|
|
// (the accept future is released with it).
|
|
drop(raw);
|
|
within(pump)
|
|
.await
|
|
.expect("the session ends on the client-gone teardown");
|
|
|
|
// The accept re-arms: a fresh BIND reaches reply#1 again (and its
|
|
// quiet wait tears down cleanly the same way).
|
|
let (mut raw, pump) = open_adopt_pump(&topo).await;
|
|
raw.greet().await.expect("greet");
|
|
raw.request(
|
|
Socks5Command::TCPBind,
|
|
&TargetAddr::Domain("fresh.test".into(), ECHO_BIND_PORT),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code1, _addr) = raw.read_reply().await.expect("reply#1");
|
|
assert_eq!(code1, consts::SOCKS5_REPLY_SUCCEEDED);
|
|
drop(raw);
|
|
within(pump)
|
|
.await
|
|
.expect("the fresh BIND session ends cleanly");
|
|
|
|
// CONNECT serves the same topology (no poisoning).
|
|
connect_round_trip(&topo, "fresh.test".into(), "again".into())
|
|
.await
|
|
.expect("fresh connect after the bind teardown");
|
|
}
|
|
|
|
/// The identity/scope matrix, `socks5:open`-only row (ADR-006):
|
|
/// CONNECT and ASSOCIATE serve; BIND is refused `ConnectionNotAllowed`
|
|
/// (0x02) at the command gate while CONNECT still serves the same
|
|
/// identity; ingress is never implied by egress.
|
|
#[tokio::test]
|
|
async fn identity_matrix_open_only_connect_associate_serve_bind_refused_0x02() {
|
|
let topo = wire_with(
|
|
RegisterMode::Producer {
|
|
dial: echo_dial(),
|
|
egress: Some(echo_egress()),
|
|
accept: Some(echo_accept()),
|
|
},
|
|
Some(consumer_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
// CONNECT serves.
|
|
connect_round_trip(&topo, "open.test".into(), "tcp".into())
|
|
.await
|
|
.expect("CONNECT serves the open-only identity");
|
|
|
|
// ASSOCIATE serves.
|
|
associate_round_trip(&topo, 40, b"udp")
|
|
.await
|
|
.expect("ASSOCIATE serves the open-only identity");
|
|
|
|
// BIND: the command gate refuses 0x02, RFC-faithful; no data
|
|
// plane (the accept seam never ran).
|
|
let (mut raw, pump) = open_adopt_pump(&topo).await;
|
|
raw.greet().await.expect("greet");
|
|
raw.request(
|
|
Socks5Command::TCPBind,
|
|
&TargetAddr::Domain("gate.test".into(), ECHO_BIND_PORT),
|
|
)
|
|
.await
|
|
.expect("request written");
|
|
let (code, _addr) = raw.read_reply().await.expect("reply read");
|
|
assert_eq!(
|
|
code,
|
|
consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED,
|
|
"the command gate's 0x02"
|
|
);
|
|
drop(raw);
|
|
within(pump).await.expect("the gated session ends");
|
|
|
|
// CONNECT still serves the same identity (the gate distinguishes
|
|
// ingress from egress, not the other way around).
|
|
connect_round_trip(&topo, "still.test".into(), "again".into())
|
|
.await
|
|
.expect("CONNECT still serves after the BIND refusal");
|
|
|
|
// The per-call opener identity the gates acted on (CF-006).
|
|
assert_eq!(
|
|
topo.identity_witness.lock().await.as_deref(),
|
|
Some("consumer")
|
|
);
|
|
}
|
|
|
|
/// The identity/scope matrix, `socks5:bind`-only row (ADR-006, as
|
|
/// landed): the open op's `required_scopes` is `socks5:open`
|
|
/// unconditionally, so a bind-only identity fails closed at the open
|
|
/// gate for all three commands (FORBIDDEN; the establisher never
|
|
/// runs; no channel anywhere). `socks5:bind` alone grants nothing
|
|
/// here; the two grants are never implied, in either direction.
|
|
#[tokio::test]
|
|
async fn identity_matrix_bind_only_fails_closed_at_the_open_gate() {
|
|
let topo = wire_with(
|
|
RegisterMode::Producer {
|
|
dial: echo_dial(),
|
|
egress: Some(echo_egress()),
|
|
accept: Some(echo_accept()),
|
|
},
|
|
Some(bind_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
// The open itself is refused: fail-closed, no channel anywhere,
|
|
// the establisher never ran (the registry's ACL gate runs first).
|
|
let response = topo
|
|
.consumer_call
|
|
.call(alksocks::OP_SOCKS5_OPEN, Socks5Params::as_json())
|
|
.await;
|
|
let err = response
|
|
.result
|
|
.expect_err("a bind-only identity fails closed");
|
|
assert_eq!(err.code, "FORBIDDEN");
|
|
assert!(topo
|
|
.consumer_manager
|
|
.channel_ids()
|
|
.into_iter()
|
|
.all(|id| id == 0));
|
|
assert!(topo
|
|
.producer
|
|
.manager()
|
|
.channel_ids()
|
|
.into_iter()
|
|
.all(|id| id == 0));
|
|
assert!(
|
|
topo.identity_witness.lock().await.is_none(),
|
|
"the establisher never ran; fail-closed"
|
|
);
|
|
|
|
// The same posture through the typed consumer sessions: the BIND
|
|
// session's open is a typed FORBIDDEN open error, no session, no
|
|
// phantom channel.
|
|
let ctopo = wire_consumer_side_with(
|
|
echo_dial(),
|
|
None,
|
|
Some(echo_accept()),
|
|
Some(bind_identity()),
|
|
)
|
|
.await;
|
|
let err = BindSession::bind(
|
|
ctopo.consumer.as_ref(),
|
|
("bind.test", ECHO_BIND_PORT).to_target_addr().unwrap(),
|
|
)
|
|
.await
|
|
.expect_err("a bind-only identity fails closed");
|
|
assert!(
|
|
expect_forbidden(&err),
|
|
"FORBIDDEN is the open error: {err:?}"
|
|
);
|
|
no_leaked_channels(&ctopo).await;
|
|
}
|
|
|
|
/// The identity/scope matrix, both-scopes row (ADR-006): both grants,
|
|
/// granted explicitly (never implied), serve every command on the one
|
|
/// registration.
|
|
#[tokio::test]
|
|
async fn identity_matrix_both_scopes_serve_every_command() {
|
|
let topo = full_raw_topology().await;
|
|
|
|
connect_round_trip(&topo, "both.test".into(), "tcp".into())
|
|
.await
|
|
.expect("CONNECT serves the both-scopes identity");
|
|
associate_round_trip(&topo, 50, b"udp")
|
|
.await
|
|
.expect("ASSOCIATE serves the both-scopes identity");
|
|
bind_round_trip(&topo, "both.test", b"bind")
|
|
.await
|
|
.expect("BIND serves the both-scopes identity");
|
|
|
|
assert_eq!(
|
|
topo.identity_witness.lock().await.as_deref(),
|
|
Some("consumer-both")
|
|
);
|
|
}
|
|
|
|
/// The readiness contract under concurrent load (the integration
|
|
/// level pin; the unit-level test is the producer-associate task's):
|
|
/// a hot `Ok(None)` egress across concurrent associations (with a
|
|
/// CONNECT interleaved) must not starve the datagram stage's channel
|
|
/// branch. Datagrams still egress and echo.
|
|
#[tokio::test]
|
|
async fn readiness_contract_holds_under_concurrent_load() {
|
|
let topo = wire_with(
|
|
RegisterMode::Producer {
|
|
dial: echo_dial(),
|
|
egress: Some(hot_none_egress()),
|
|
accept: None,
|
|
},
|
|
Some(both_scopes_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
const UDP_TAGS: [&[u8]; 2] = [b"hot-zero0", b"hot-one!!"];
|
|
let mut futs: Vec<RoundTrip> = UDP_TAGS
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, tag)| udp_fut(&topo, 40 + i as u8, tag))
|
|
.collect();
|
|
futs.push(tcp_fut(&topo, "hot.test".into(), "tcp".into()));
|
|
let results = futures::future::join_all(futs).await;
|
|
for (i, result) in results.into_iter().enumerate() {
|
|
result.unwrap_or_else(|e| panic!("the stage starved under load (session {i}): {e}"));
|
|
}
|
|
|
|
// The stage is still healthy: another datagram round-trips.
|
|
associate_round_trip(&topo, 50, b"again")
|
|
.await
|
|
.expect("fresh associate after the hot-egress load");
|
|
}
|
|
|
|
/// The spec'd assembly shape end to end (review 002 D-2): the full
|
|
/// `Socks5Producer::builder()` chain (`dial` + `egress` + `accept`)
|
|
/// through `.register(...)` (the builder's delegated registration, not
|
|
/// the free function), serving all three commands over the harness
|
|
/// topology. The one assembly path producer.md §Wiring documents is
|
|
/// the one this test pins: a signature drift or a delegation bug in
|
|
/// `register` can no longer pass the suite.
|
|
#[tokio::test]
|
|
async fn producer_builder_registers_and_serves_all_three_commands() {
|
|
let topo = wire_with(
|
|
RegisterMode::ProducerBuilder {
|
|
dial: echo_dial(),
|
|
egress: Some(echo_egress()),
|
|
accept: Some(echo_accept()),
|
|
bind_policy: None,
|
|
},
|
|
Some(both_scopes_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
// CONNECT over the builder's dial seam.
|
|
connect_round_trip(&topo, "builder.test".into(), "tcp".into())
|
|
.await
|
|
.expect("CONNECT serves through the builder registration");
|
|
|
|
// ASSOCIATE over the builder's egress seam.
|
|
associate_round_trip(&topo, 60, b"udp")
|
|
.await
|
|
.expect("ASSOCIATE serves through the builder registration");
|
|
|
|
// BIND over the builder's accept seam (reply#1 + reply#2 +
|
|
// pass-through: the full phase model).
|
|
bind_round_trip(&topo, "builder.test", b"bind")
|
|
.await
|
|
.expect("BIND serves through the builder registration");
|
|
|
|
// The registration is the spec'd one: the establisher rode it
|
|
// (the witness is the builder-delegated registration's probe).
|
|
assert_eq!(
|
|
topo.identity_witness.lock().await.as_deref(),
|
|
Some("consumer-both")
|
|
);
|
|
}
|