producer: the BIND phase model + the socks5:bind command gate (ADR-006/ADR-007)

The TCP BIND dispatch arm replaces its refusal with the full phase
model (POC #5-validated scope), plus the command gate at dispatch:

- The command gate: TCP BIND requires socks5:bind from the per-call
  opener identity (CF-005/CF-006) — the pump handler resolves it
  from the open-op AuthContext; serve_socks5_over<T> takes the
  resolved can_bind boolean explicitly. A scope-less identity gets
  the RFC-faithful ConnectionNotAllowed (0x02) with no factory call
  (the gate precedes the seam) while CONNECT still serves it.
- The phase model (wire.md §BIND): the BindAcceptFactory at the
  command-read interception point → reply#1 carrying BND.ADDR (the
  factory's bind_addr) → the quiet wait (single-shot select!, exactly
  two exits: the one accept, or the control stream's EOF/garbage/
  error; any byte = protocol garbage → session ends, no data plane;
  the accept future's drop IS the client-gone teardown) → reply#2
  carrying the peer address (via the fork's published new_reply) →
  pump_bidi inline (the CONNECT shape, R-02).
- Two-stage RFC-faithful failure mapping: factory failure → reply#1's
  mapped code; accept-stage failure → reply#2's mapped code, no data
  plane. No phantom listeners, no phantom data planes.
- The no-factory default is the wrapper-aware in-band variant
  (ADR-007 variant 3, documented): sentinel reply#1 + sentinel reply#2
  (0.0.0.0:0), the full phase model preserved — the channel is the
  connection.
- The accept wait lives in the teardown-governed pump phase, never
  under the establishment bound (ADR-007).

Tests: 16 unit tests (raw RFC client over duplex) + 7 harness
integration tests over channels — happy path, the gate (0x02 while
CONNECT serves, both scopes), quiet-wait garbage, client-gone
teardown (owner-flag witness), both failure stages over channels,
no-factory variant 3, and the three-command regression guard (CONNECT
serves, BIND serves under its gate, ASSOCIATE still refuses).

Verification: cargo test green (96 lib + 26 harness + 37
differential); clippy -D warnings clean (all targets, native +
wasm32-unknown-unknown); cargo fmt clean; wasm check clean.

Task: tasks/implementation/core-producer-bind.md (status → completed)
This commit is contained in:
glm-5.3-flash committed 2026-09-23 19:58:31 +00:00
1 parent cfacc8a968
commit 048493fefa
4 files changed
+1219 -91

No files matched your search

+2 -1
View File
@@ -38,4 +38,5 @@ bytes = "1"
futures = { version = "0.3", default-features = false, features = ["std"] }
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util", "macros"] }
tokio = { version = "1", features = ["full", "test-util", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+709 -63
View File
@@ -40,11 +40,23 @@
//! birth). Refusal: `reply_error` with the mapped RFC code
//! (dial-failure → `ConnectionRefused` 0x05; scope-class →
//! `ConnectionNotAllowed` 0x02) — no phantom data plane.
//! - **UDP ASSOCIATE / TCP BIND** → this task lands the dispatch
//! arms as RFC-faithful refusals (`CommandNotSupported` — the
//! pre-vertical posture, ADR-008's "a missing callback means that
//! command refuses RFC-faithfully, never a panic"); the associate/
//! bind producer tasks replace them.
//! - **UDP ASSOCIATE** → RFC-faithful refusal in this task's scope
//! (`CommandNotSupported` — ADR-008's missing-callback class); the
//! producer-associate task replaces it.
//! - **TCP BIND** → the command gate first (`socks5:bind`, ADR-006,
//! from the per-call opener identity — a scope-less identity gets
//! `ConnectionNotAllowed` 0x02 while CONNECT still serves it), then
//! the phase model (ADR-007): the accept seam
//! ([`BindAcceptFactory`], ADR-004) at the command-read
//! interception point, reply#1 carrying BND.ADDR, the quiet wait
//! (single-shot, exactly two exits — the accept, or the control
//! stream's EOF/garbage/error), reply#2 carrying the peer address,
//! then `pump_bidi` inline. Two-stage failure mapping: factory
//! failure → reply#1's code; accept-stage failure → reply#2's
//! code, no data plane. No factory wired = the wrapper-aware
//! in-band default (ADR-007 variant 3): sentinel reply#1 (via the
//! accept future's instant resolution) + sentinel reply#2
//! (`0.0.0.0:0`).
//!
//! The handler is substrate-agnostic by construction — it sees boxed
//! halves, never a socket type (AGENTS.md convention 7; the
@@ -54,10 +66,13 @@
//!
//! [`serve_socks5_over`] is the substrate-free protocol entry point
//! (the POC shape): generic over `T: AsyncRead + AsyncWrite + Unpin`,
//! driving the typestate and dispatching. The channel `BiStream` and
//! a `TcpStream` are equally `T` — this is what makes the producer
//! testable without channels (the differential-suite pattern) and
//! reusable by the batch-2 local backend (feature `local`, ADR-009).
//! driving the typestate and dispatching, with the `can_bind` gate
//! carried explicitly (the open-op identity seam is consumed by the
//! pump handler — the substrate-free entry takes the resolved gate
//! boolean). The channel `BiStream` and a `TcpStream` are equally
//! `T` — this is what makes the producer testable without channels
//! (the differential-suite pattern) and reusable by the batch-2
//! local backend (feature `local`, ADR-009).
//!
//! ## Registration
//!
@@ -82,9 +97,9 @@ use alkcall::registry::registration::OperationRegistry;
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::egress::{BindAcceptFactory, DialFn, EgressFactory};
use crate::params::socks5_open_spec;
use crate::rfc1928::server::{Socks5ServerProtocol, SocksServerError};
use crate::egress::{BindAccept, BindAcceptFactory, BindTargetHandle, DialFn, EgressFactory};
use crate::params::{socks5_open_spec, SOCKS5_BIND_SCOPE};
use crate::rfc1928::server::{new_reply, Socks5ServerProtocol, SocksServerError};
use crate::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::{ReplyError, Socks5Command, SocksError};
@@ -128,7 +143,10 @@ pub fn socks5_session_establisher(witness: Option<IdentityWitness>) -> OpenEstab
/// The pump handler: accept the channel's yield-once `BiStream`
/// (`accept_bi` first — the wrapper's accepted-flag telemetry), then
/// run the RFC 1928 state machine over it and dispatch on the
/// command. The handler is substrate-agnostic by construction — it
/// command. The command gate rides the open-op `AuthContext`'s
/// per-call opener identity (CF-005/CF-006): TCP BIND requires
/// `socks5:bind` (ADR-006) — CONNECT and UDP ASSOCIATE need only the
/// open gate. The handler is substrate-agnostic by construction — it
/// sees boxed halves, never a socket type (ADR-004's inversion
/// point). Awaits the data plane inline (R-02 — the returned
/// `JoinHandle` tracks the data-plane lifetime; early return =
@@ -142,16 +160,21 @@ pub fn make_socks5_pump_handler(
move |_input: Value,
_plan: Option<alkcall::channels::operations::ChannelPlan>,
conn: Connection,
_auth: AuthContext| {
auth: AuthContext| {
let dial = dial.clone();
let egress = egress.clone();
let accept = accept.clone();
let can_bind = auth
.identity
.as_ref()
.map(|i| i.scopes.iter().any(|s| s == SOCKS5_BIND_SCOPE))
.unwrap_or(false);
tokio::spawn(async move {
let Ok(bidi) = conn.accept_bi().await else {
tracing::error!("socks5 pump: accept_bi failed");
return;
};
if let Err(err) = serve_socks5_over(bidi, dial, egress, accept).await {
if let Err(err) = serve_socks5_over(bidi, dial, egress, accept, can_bind).await {
tracing::warn!("socks5 session ended: {err:#}");
}
})
@@ -164,12 +187,16 @@ pub fn make_socks5_pump_handler(
/// `TcpStream`, a duplex half — the typestate and the seams never
/// know. `dial`/`egress`/`accept` are the injected seams (ADR-004);
/// a missing seam makes its command refuse RFC-faithfully
/// (`CommandNotSupported`-class, ADR-008) — never a panic.
/// (`CommandNotSupported`-class, ADR-008) — never a panic. `can_bind`
/// is the command gate's resolved boolean (ADR-006 — the pump handler
/// resolves it from the per-call opener identity; a substrate-free
/// caller passes its policy's answer).
pub async fn serve_socks5_over<T>(
stream: T,
dial: Option<DialFn>,
egress: Option<EgressFactory>,
_accept: Option<BindAcceptFactory>,
accept: Option<BindAcceptFactory>,
can_bind: bool,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
@@ -182,7 +209,19 @@ where
match cmd {
Socks5Command::TCPConnect => serve_connect(proto, target, dial).await,
Socks5Command::UDPAssociate => serve_udp_associate(proto, egress).await,
Socks5Command::TCPBind => serve_tcp_bind_refusal(proto).await,
Socks5Command::TCPBind => {
if !can_bind {
// The command gate (ADR-006): BIND mints inbound
// listeners — an identity with proxy egress must not
// imply ingress. RFC-faithful refusal (the command is
// known, the caller is not allowed).
proto.reply_error(&ReplyError::ConnectionNotAllowed).await?;
return Err(SocksError::ServerError(
SocksServerError::AuthenticationRejected,
));
}
serve_tcp_bind(proto, target, accept).await
}
}
}
@@ -238,19 +277,147 @@ where
)))
}
/// TCP BIND: RFC-faithful refusal in this task's scope — the phase
/// model lands with the producer-bind task (the pre-vertical
/// posture; ADR-008's missing-callback class).
async fn serve_tcp_bind_refusal<T>(
/// TCP BIND: the phase model (ADR-007; POC #5-validated). Phases:
///
/// 1. **Accept factory** — the CONNECT dial's mirror, called at the
/// command-read interception point with the requested target. A
/// factory failure maps to reply#1 (no phantom listener). No
/// factory wired = the wrapper-aware in-band default (ADR-007
/// variant 3): a synthetic accept whose reply#1 is the sentinel —
/// the handle-swap reading applied to BIND (the reply is
/// RFC-compatibility surface; the data plane is the channel).
/// 2. **Reply#1** — success + BND.ADDR (the factory's `bind_addr`:
/// the real bound listener on the provider-backed variants, the
/// sentinel on the wrapper-aware one).
/// 3. **The quiet wait** — single-shot `select!` with exactly two
/// exits (the POC's `never_loop` finding): the one inbound accept
/// (the `BindAccept` future — its drop IS the client-gone
/// teardown of whatever the provider opened), or the control
/// stream's EOF/garbage/error. Not a loop: any byte in the quiet
/// wait is protocol garbage (a BIND client never sends another
/// control byte — RFC 1928 §3's post-reply semantic) → the
/// session ends with no data plane. The wait lives in the
/// teardown-governed pump phase, never under the establishment
/// bound (FTP-length BIND waits stay legal).
/// 4. **Reply#2** — the peer address (the accepted handle's peer;
/// the wrapper-aware sentinel is `0.0.0.0:0`). An accept-stage
/// failure maps to reply#2's code — no data plane.
/// 5. **Data plane** — `pump_bidi` inline (the CONNECT shape, R-02):
/// raw pass-through between the channel and the accepted halves.
async fn serve_tcp_bind<T>(
proto: Socks5ServerProtocol<T, crate::rfc1928::server::states::CommandRead>,
target: TargetAddr,
accept: Option<BindAcceptFactory>,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
proto.reply_error(&ReplyError::CommandNotSupported).await?;
Err(SocksError::ServerError(SocksServerError::UnknownCommand(
Socks5Command::TCPBind.as_u8(),
)))
let accept = match accept {
Some(factory) => match factory(target).await {
Ok(accept) => accept,
Err(err) => {
proto.reply_error(&err.to_reply_error()).await?;
return Err(SocksError::ServerError(SocksServerError::Bug(
"bind factory refused in-stream",
)));
}
},
None => in_band_bind_accept(),
};
// Reply#1: success + BND.ADDR — the factory's address is the
// carrier (real listener / advertised composed address / the
// wrapper-aware sentinel).
let inner = proto.reply_success(accept.bind_addr()).await?;
// The quiet wait → reply#2 + the data plane.
bind_quiet_wait_and_serve(inner, accept).await
}
/// The no-factory in-band default (ADR-007 variant 3, wrapper-aware):
/// the sentinel reply#1 rides a synthetic accept whose future
/// resolves instantly — the phase model is preserved end to end
/// (reply#1 → quiet wait → reply#2 → pass-through) with the channel
/// itself as the accepted "connection". Reply#2's peer is the
/// wrapper-aware sentinel `0.0.0.0:0`.
fn in_band_bind_accept() -> BindAccept {
BindAccept::new(sentinel_bind_peer_addr(), async {
Ok(BindTargetHandle {
peer: TargetAddr::Ip(sentinel_bind_peer_addr()),
read: Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Send + Sync + Unpin>,
write: Box::new(tokio::io::sink()) as Box<dyn AsyncWrite + Send + Sync + Unpin>,
})
})
}
/// The quiet wait + reply#2 + the data plane. Single-shot `select!`
/// with exactly two exits (the accept, or the control stream's
/// EOF/garbage/error); the accept branch's error maps to reply#2's
/// code — no data plane; the accept success writes reply#2 with the
/// peer address then `pump_bidi`s inline.
async fn bind_quiet_wait_and_serve<T>(mut inner: T, accept: BindAccept) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut chunk = [0u8; 1];
let wait = accept.accept();
tokio::pin!(wait);
let handle = tokio::select! {
handle = &mut wait => match handle {
Ok(h) => h,
Err(err) => {
// Accept-stage failure (post-reply#1): reply#2
// carries the mapped code; the session ends — no
// data plane.
let _ = inner
.write_all(&new_reply(&err.to_reply_error(), sentinel_bind_peer_addr()))
.await;
let _ = inner.flush().await;
return Err(SocksError::ServerError(SocksServerError::Bug(
"bind accept refused in-stream",
)));
}
},
read = inner.read(&mut chunk) => match read {
// Client gone: the accept future drops here — whatever
// the provider opened (the listener, the tunnel) is
// owned by that future, so it tears down with the
// session (the client-gone contract, ADR-004/ADR-007).
Ok(0) => return Ok(()),
// Any byte in the quiet wait is protocol garbage (a BIND
// client never sends another control byte) — the session
// ends with no data plane, no reply#2.
Ok(_) => {
return Err(SocksError::ServerError(
SocksServerError::UnexpectedUdpControlGarbage(chunk[0]),
));
}
Err(_) => return Ok(()),
},
};
// Reply#2: success + the accepted peer address, then the raw
// pass-through data plane (the CONNECT shape), awaited inline.
let peer = match handle.peer {
TargetAddr::Ip(addr) => addr,
TargetAddr::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port),
};
let mut inner = inner;
inner
.write_all(&new_reply(&ReplyError::Succeeded, peer))
.await?;
inner.flush().await?;
pump_bidi(inner, handle.read, handle.write).await;
Ok(())
}
/// The wrapper-aware sentinel peer (reply#2's BND.ADDR on the
/// in-band path): `0.0.0.0:0` per wire.md §BIND.
fn sentinel_bind_peer_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
}
/// Register the SOCKS5 open op on a `ChannelCore` + the session's
@@ -298,7 +465,11 @@ impl Socks5Producer {
}
/// The substrate-free protocol entry (the same shape
/// [`serve_socks5_over`] pins) over the builder's seams.
/// [`serve_socks5_over`] pins) over the builder's seams. The
/// command gate is resolved from the builder's `accept` presence:
/// a producer that wired the accept seam serves BIND to whatever
/// calls this entry directly (the per-caller gate is the pump
/// handler's, resolved from the open-op identity — ADR-006).
pub async fn serve_socks5_over<T>(&self, stream: T) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
@@ -308,6 +479,7 @@ impl Socks5Producer {
self.dial.clone(),
self.egress.clone(),
self.accept.clone(),
self.accept.is_some(),
)
.await
}
@@ -373,7 +545,7 @@ impl Socks5ProducerBuilder {
#[cfg(test)]
mod tests {
use super::*;
use crate::egress::{SocksDialError, TargetHandle};
use crate::egress::{BindAcceptError, BindAcceptFactoryError, SocksDialError, TargetHandle};
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
/// The test dial closure (the harness's echo shape, inline): one
@@ -425,7 +597,8 @@ mod tests {
/// Drive a raw RFC 1928 client against [`serve_socks5_over`] over
/// a duplex (the differential-suite pattern: the producer
/// testable without channels).
/// testable without channels). `can_bind` defaults to the
/// CONNECT-only shape (the gate's no-grant answer).
async fn raw_connect(
dial: DialFn,
) -> (
@@ -433,7 +606,22 @@ mod tests {
DuplexStream,
) {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let handle = tokio::spawn(serve_socks5_over(server_end, Some(dial), None, None));
let handle = tokio::spawn(serve_socks5_over(server_end, Some(dial), None, None, false));
(handle, client_end)
}
/// The same shape with the seams + gate chosen explicitly (the
/// BIND tests' entry).
async fn raw_connect_with(
dial: Option<DialFn>,
accept: Option<BindAcceptFactory>,
can_bind: bool,
) -> (
tokio::task::JoinHandle<Result<(), SocksError>>,
DuplexStream,
) {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let handle = tokio::spawn(serve_socks5_over(server_end, dial, None, accept, can_bind));
(handle, client_end)
}
@@ -568,7 +756,7 @@ mod tests {
#[tokio::test]
async fn missing_dial_refuses_connect_command_not_supported() {
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let handle = tokio::spawn(serve_socks5_over(server_end, None, None, None));
let handle = tokio::spawn(serve_socks5_over(server_end, None, None, None, false));
let mut client = client_end;
greet(&mut client).await;
write_request(
@@ -591,42 +779,35 @@ mod tests {
));
}
/// The ASSOCIATE and BIND dispatch arms refuse RFC-faithfully in
/// this task's scope (`CommandNotSupported` — the pre-vertical
/// posture; the producer-associate/bind tasks replace them). The
/// dispatch skeleton exists from day one so the regression guard
/// (one registration, all three commands) is testable early.
/// The ASSOCIATE dispatch arm refuses RFC-faithfully in this
/// task's scope (`CommandNotSupported` — the pre-vertical
/// posture; the producer-associate task replaces it). BIND now
/// serves under the gate — its tests live below.
#[tokio::test]
async fn associate_and_bind_arms_refuse_command_not_supported() {
async fn associate_arm_refuses_command_not_supported() {
let dial = echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new())));
for cmd in [Socks5Command::UDPAssociate, Socks5Command::TCPBind] {
let (handle, mut client) = raw_connect(Arc::clone(&dial)).await;
greet(&mut client).await;
let req_target = TargetAddr::Domain("later.task".into(), 1080);
write_request(&mut client, &cmd, &req_target).await;
let (code, _addr) = read_reply(&mut client).await;
let cmd_name = match cmd {
Socks5Command::UDPAssociate => "ASSOCIATE",
Socks5Command::TCPBind => "BIND",
Socks5Command::TCPConnect => "CONNECT",
};
assert_eq!(code, 0x07, "{cmd_name} refuses CommandNotSupported");
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("refusal surfaced in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::UnknownCommand(_))
));
}
let (handle, mut client) = raw_connect(dial).await;
greet(&mut client).await;
let req_target = TargetAddr::Domain("later.task".into(), 1080);
write_request(&mut client, &Socks5Command::UDPAssociate, &req_target).await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x07, "ASSOCIATE refuses CommandNotSupported");
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("refusal surfaced in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::UnknownCommand(_))
));
}
/// The dispatch skeleton + the two-pump shape coexist: CONNECT
/// serves, ASSOCIATE/BIND refuse — one `serve_socks5_over`, all
/// three commands (the early regression guard).
/// serves, ASSOCIATE refuses, BIND is gated — one
/// `serve_socks5_over`, all three commands (the regression
/// guard).
#[tokio::test]
async fn dispatch_guard_connect_serves_while_associate_bind_refuse() {
let dial = echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new())));
@@ -651,6 +832,445 @@ mod tests {
.expect("join");
}
// -- BIND: the phase model (ADR-007; the POC #5 shape, in-process)
// ---------------------------------------------------------------
/// An accept factory whose future resolves instantly (reply#1 →
/// reply#2 race-free for the happy-path tests).
fn bind_accept_factory(
requested: Arc<tokio::sync::Mutex<Option<TargetAddr>>>,
) -> BindAcceptFactory {
Arc::new(move |target: TargetAddr| {
let requested = Arc::clone(&requested);
Box::pin(async move {
*requested.lock().await = Some(target.clone());
let bind_addr = SocketAddr::from(([127, 0, 0, 1], 2121));
let accept_future = async {
let (mut echo_end, target_end) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
loop {
let mut buf = vec![0u8; 4096];
match echo_end.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if echo_end.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
let (read_half, write_half) = tokio::io::split(target_end);
Ok(BindTargetHandle {
peer: TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 7], 44444))),
read: Box::new(read_half) as Box<dyn AsyncRead + Send + Sync + Unpin>,
write: Box::new(write_half) as Box<dyn AsyncWrite + Send + Sync + Unpin>,
})
};
Ok(BindAccept::new(bind_addr, accept_future))
})
})
}
/// An accept factory whose future parks forever (the quiet wait's
/// other exits are then deterministic — garbage/EOF win, the
/// accept never does).
fn parked_accept_factory() -> BindAcceptFactory {
Arc::new(move |_target: TargetAddr| {
Box::pin(async move {
Ok(BindAccept::new(
SocketAddr::from(([127, 0, 0, 1], 2121)),
std::future::pending::<Result<BindTargetHandle, BindAcceptError>>(),
))
})
})
}
/// The BIND happy path through the phase model: reply#1 carries
/// the factory's BND.ADDR, reply#2 carries the peer address, and
/// the data plane is raw pass-through (echo both directions).
#[tokio::test]
async fn bind_round_trip_reply1_reply2_then_pass_through() {
let requested = Arc::new(tokio::sync::Mutex::new(None));
let accept = bind_accept_factory(Arc::clone(&requested));
let (handle, mut client) = raw_connect_with(None, Some(accept), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("bind.test".into(), 2121),
)
.await;
// Reply#1: success + the factory's advertised BND.ADDR.
let (code, addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "reply#1 success");
assert_eq!(
SocketAddr::from((
[addr[0], addr[1], addr[2], addr[3]],
u16::from_be_bytes([addr[4], addr[5]])
)),
SocketAddr::from(([127, 0, 0, 1], 2121)),
"reply#1 carries the factory's bind_addr"
);
// The factory saw the requested target (the interception
// point — the CONNECT dial's mirror).
assert_eq!(
requested.lock().await.clone(),
Some(TargetAddr::Domain("bind.test".into(), 2121))
);
// Reply#2: success + the accepted peer address.
let (code, addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "reply#2 success");
assert_eq!(
SocketAddr::from((
[addr[0], addr[1], addr[2], addr[3]],
u16::from_be_bytes([addr[4], addr[5]])
)),
SocketAddr::from(([10, 0, 0, 7], 44444)),
"reply#2 carries the accepted peer"
);
// Phase 2: raw pass-through, both directions (the CONNECT
// shape — zero per-packet framing).
client.write_all(b"ping").await.unwrap();
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
client.write_all(b"pong").await.unwrap();
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"pong");
// Half-close teardown: client EOF ends the session cleanly.
drop(client);
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends on client EOF")
.expect("join");
result.expect("clean session");
}
/// The command gate (ADR-006; POC #5 test 4): BIND without
/// `socks5:bind` gets the RFC-faithful `ConnectionNotAllowed`
/// (0x02) — while CONNECT still serves the same registration
/// (the gate distinguishes ingress from egress; the scopes are
/// never implied).
#[tokio::test]
async fn bind_command_gate_refuses_0x02_while_connect_still_serves() {
// The gate closed: 0x02 on BIND — no factory call (the gate
// precedes the seam; a scope-less BIND must never mint a
// listener).
let requested = Arc::new(tokio::sync::Mutex::new(None));
let accept = bind_accept_factory(Arc::clone(&requested));
let (handle, mut client) = raw_connect_with(None, Some(accept), false).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("gate.test".into(), 2121),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x02, "the gate refuses ConnectionNotAllowed");
assert!(
requested.lock().await.is_none(),
"the gate precedes the accept seam — no phantom listener"
);
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("the refusal surfaces in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::AuthenticationRejected)
));
// CONNECT still serves the same identity (egress unaffected).
let dial = echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new())));
let (handle, mut client) = raw_connect_with(Some(dial), None, false).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("still.ok".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "CONNECT is unaffected by the bind gate");
drop(client);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join");
}
/// The quiet wait: a control byte after reply#1 is protocol
/// garbage — the session ends with no data plane (no reply#2,
/// no bytes echo).
#[tokio::test]
async fn bind_quiet_wait_garbage_byte_ends_the_session() {
let accept = parked_accept_factory();
let (handle, mut client) = raw_connect_with(None, Some(accept), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("quiet.test".into(), 2121),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "reply#1 succeeded");
// Protocol garbage in the quiet wait (a BIND client never
// sends another control byte).
client.write_all(&[0x05, 0x02]).await.unwrap();
// The session ends: EOF, no reply#2, no data plane.
let mut buf = [0u8; 10];
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut buf)).await;
match outcome {
Err(_) => panic!("the session must end on quiet-wait garbage"),
Ok(Err(_)) => {}
Ok(Ok(0)) => {}
Ok(Ok(n)) => panic!("no reply#2 after garbage — got {n} bytes"),
}
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("garbage surfaces as the control-garbage error");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::UnexpectedUdpControlGarbage(_))
));
}
/// Client-gone teardown: EOF mid-quiet-wait drops the accept
/// future (the client-gone contract — whatever the provider
/// opened dies with it). The session ends cleanly with no reply#2.
#[tokio::test]
async fn bind_client_gone_teardown_drops_the_accept_future() {
// The accept future owns a listener-shaped resource: when the
// future drops, the flag flips (the client-gone teardown made
// observable).
use std::sync::atomic::{AtomicBool, Ordering};
let dropped = Arc::new(AtomicBool::new(false));
let factory: BindAcceptFactory = {
let dropped = Arc::clone(&dropped);
Arc::new(move |_target: TargetAddr| {
let dropped = Arc::clone(&dropped);
Box::pin(async move {
struct Owned(Arc<AtomicBool>);
impl Drop for Owned {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let owner = Owned(Arc::clone(&dropped));
Ok(BindAccept::new(
SocketAddr::from(([127, 0, 0, 1], 2121)),
async move {
// Park forever (the wait's other branch
// wins); the owner's drop fires on future
// teardown — the client-gone contract.
std::future::pending::<()>().await;
drop(owner);
unreachable!("pending never resolves")
},
))
})
})
};
let (handle, mut client) = raw_connect_with(None, Some(factory), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("gone.test".into(), 2121),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "reply#1 succeeded");
// The client vanishes mid-wait: EOF → the accept future
// drops → the owned resource tears down.
drop(client);
tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends on client EOF")
.expect("join")
.expect("clean session (EOF is not an error)");
assert!(
dropped.load(Ordering::SeqCst),
"the accept future's owned resource dropped with the session"
);
}
/// Factory failure (pre-reply#1) maps to reply#1's code — no
/// phantom listener (the factory never returned one).
#[tokio::test]
async fn bind_factory_failure_maps_to_reply1() {
let factory: BindAcceptFactory = Arc::new(move |_target: TargetAddr| {
Box::pin(async move { Err(BindAcceptFactoryError::Refused("port held".into())) })
});
let (handle, mut client) = raw_connect_with(None, Some(factory), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("factory.fail".into(), 2121),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x05, "factory refusal → reply#1 ConnectionRefused");
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("the refusal surfaces in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::Bug("bind factory refused in-stream"))
));
}
/// Accept-stage failure (post-reply#1) maps to reply#2's code —
/// no data plane.
#[tokio::test]
async fn bind_accept_stage_failure_maps_to_reply2() {
let factory: BindAcceptFactory = Arc::new(move |_target: TargetAddr| {
Box::pin(async move {
Ok(BindAccept::new(
SocketAddr::from(([127, 0, 0, 1], 2121)),
async move { Err(BindAcceptError::NotAllowed("policy".into())) },
))
})
});
let (handle, mut client) = raw_connect_with(None, Some(factory), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("accept.fail".into(), 2121),
)
.await;
let (code1, _addr) = read_reply(&mut client).await;
assert_eq!(code1, 0x00, "reply#1 succeeded — the factory resolved");
let (code2, _addr) = read_reply(&mut client).await;
assert_eq!(
code2, 0x02,
"accept-stage refusal → reply#2 ConnectionNotAllowed"
);
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("the refusal surfaces in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::Bug("bind accept refused in-stream"))
));
}
/// The no-factory default (ADR-007 variant 3, wrapper-aware): the
/// sentinel reply#1 + the sentinel reply#2 (`0.0.0.0:0`), the
/// full phase model preserved end to end (quiet wait included —
/// garbage still ends the session), then raw pass-through (the
/// in-band halves are empty/sink — bytes flow one way to the
/// sink, EOF comes back).
#[tokio::test]
async fn bind_no_factory_serves_the_wrapper_aware_variant_3() {
let (handle, mut client) = raw_connect_with(None, None, true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("variant3.test".into(), 2121),
)
.await;
let (code, addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "variant 3 serves reply#1");
assert_eq!(
(addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]),
(0, 0, 0, 0, 0, 0),
"reply#1 carries the wrapper-aware sentinel"
);
let (code, addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "variant 3 serves reply#2");
assert_eq!(
(addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]),
(0, 0, 0, 0, 0, 0),
"reply#2 carries the wrapper-aware sentinel peer"
);
// The data plane exists (the channel is the connection): the
// in-band halves are empty/sink, so the client's write lands
// and EOF follows.
client.write_all(b"hi").await.unwrap();
let mut eof = [0u8; 1];
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut eof))
.await
.expect("read resolves");
assert_eq!(outcome.unwrap_or(0), 0, "the empty half EOFs");
drop(client);
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join");
result.expect("clean session");
}
/// The establishment bound covers only the open-op round trip
/// (ADR-007): the quiet wait outlives any establishment-length
/// timeout — a client that BINDs and stays silent (no dial) keeps
/// the session alive well past the alkcall 10s default bound.
#[tokio::test]
async fn bind_quiet_wait_is_not_under_the_establishment_bound() {
let accept = parked_accept_factory();
let (handle, mut client) = raw_connect_with(None, Some(accept), true).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPBind,
&TargetAddr::Domain("ftp.wait".into(), 2121),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "reply#1 succeeded");
// Stay silent past the 10s establishment bound — the wait is
// teardown-governed, not establishment-governed. (The
// substrate-free shape has no alkcall bound; the harness
// integration test pins the over-channels shape. Here the
// parked accept still answers after a sleep — the wait is
// alive, not under any in-stream timeout.)
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
client.write_all(b"x").await.unwrap();
let mut buf = [0u8; 1];
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut buf)).await;
match outcome {
Err(_) => panic!("the session must end on quiet-wait garbage"),
Ok(Ok(0)) | Ok(Err(_)) => {}
Ok(Ok(n)) => panic!("no reply#2 after garbage — got {n} bytes"),
}
drop(client);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join");
}
/// The pump handler's shape: `accept_bi` → the same conversation,
/// with the handler's `JoinHandle` tracking the data plane (R-02
/// — the spawn shape is inline-await, not spawn-and-forget).
@@ -669,7 +1289,7 @@ mod tests {
// harness's integration tests.
let _ = &handler;
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let serve = tokio::spawn(serve_socks5_over(server_end, Some(dial), None, None));
let serve = tokio::spawn(serve_socks5_over(server_end, Some(dial), None, None, false));
let mut client = client_end;
greet(&mut client).await;
write_request(
@@ -722,6 +1342,32 @@ mod tests {
);
}
/// The command gate's resolved boolean rides the open-op
/// `AuthContext`'s identity scopes (CF-005/CF-006, ADR-006): the
/// gate is `socks5:bind`-scoped — never implied by `socks5:open`.
#[test]
fn pump_handler_resolves_the_bind_gate_from_the_opener_identity() {
fn gate(scopes: &[&str]) -> bool {
let auth = AuthContext {
identity: Some(alkcall::core::auth::Identity {
id: "caller".to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: Default::default(),
}),
..AuthContext::anonymous(b"alk/socks5")
};
auth.identity
.as_ref()
.map(|i| i.scopes.iter().any(|s| s == SOCKS5_BIND_SCOPE))
.unwrap_or(false)
}
assert!(!gate(&[SOCKS5_OPEN_SCOPE]), "open does not imply bind");
assert!(gate(&[SOCKS5_BIND_SCOPE]));
assert!(gate(&[SOCKS5_OPEN_SCOPE, SOCKS5_BIND_SCOPE]));
assert!(!gate(&[]), "scope-less fails closed");
}
/// The builder assembly shape (producer.md §Wiring): seams ride
/// the builder; `serve_socks5_over` over the built producer
/// behaves like the free function.
+80 -10
View File
@@ -1,7 +1,7 @@
---
id: implementation/core-producer-bind
name: "Core — producer BIND: the phase model + the socks5:bind command gate"
status: pending
status: completed
depends_on: [implementation/core-producer-connect]
scope: moderate
risk: medium
@@ -72,16 +72,16 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Acceptance Criteria
- [ ] The command gate: `socks5:bind` required at BIND dispatch;
- [x] The command gate: `socks5:bind` required at BIND dispatch;
`ConnectionNotAllowed` refusal; CONNECT unaffected (tested)
- [ ] The phase model: reply#1 → quiet wait (single-shot, two
- [x] The phase model: reply#1 → quiet wait (single-shot, two
exits) → accept → reply#2 → `pump_bidi` pass-through
- [ ] Two-stage failure mapping (factory → reply#1; accept-stage →
- [x] Two-stage failure mapping (factory → reply#1; accept-stage →
reply#2), tested
- [ ] The accept wait is NOT under the establishment bound
- [ ] No-factory default = wrapper-aware variant 3 (documented)
- [ ] Three-command regression guard passes
- [ ] `cargo test` green; clippy/fmt clean; both wasm checks clean
- [x] The accept wait is NOT under the establishment bound
- [x] No-factory default = wrapper-aware variant 3 (documented)
- [x] Three-command regression guard passes
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
## References
@@ -96,8 +96,78 @@ Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs`
## Notes
> To be filled by implementation agent
- **The gate seam**: `serve_socks5_over<T>` gained a `can_bind: bool`
parameter — the pump handler resolves it from the open-op
`AuthContext`'s per-call opener identity (CF-005/CF-006: the
`socks5:bind` scope, never implied by `socks5:open`); the
substrate-free entry takes the resolved boolean explicitly, so the
gate is caller-policy at the raw-entry shape and
identity-resolved at the channels shape. A scope-less BIND gets
`ConnectionNotAllowed` (0x02) with no factory call (the gate
precedes the seam — no phantom listener), while CONNECT still
serves the same identity (tested unit + over-channels).
- **Variant 3 as the no-factory default** (documented in
`serve_tcp_bind`'s docs): when no `BindAcceptFactory` is wired, the
BIND arm synthesizes a `BindAccept` — the sentinel reply#1 (via
`sentinel_bind_peer_addr()` = `0.0.0.0:0` riding the accept
future's instant resolution, so the full phase model is preserved:
reply#1 → quiet wait → reply#2 → pass-through) + the sentinel
reply#2 peer. The channel is the connection (the handle-swap
reading); the in-band halves are `tokio::io::empty`/`sink` — the
data plane exists and EOFs.
- **The quiet wait** is a single-shot `select!` with exactly two
exits (`bind_quiet_wait_and_serve`): the accept branch (its drop IS
the client-gone teardown — verified by an owner-guard test), or the
control stream's read (EOF = clean end, any byte = protocol
garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data
plane). Not a loop (the POC's `never_loop` finding).
- **Reply#2** uses the fork's published `new_reply` (carried change
#2's payoff — no local re-encode); the peer address carries as
`TargetAddr::Ip` → the real socket addr, domain-form peer → v4
unspecified with the port.
- **Failure mapping**: factory failure → reply#1's mapped code, then
`Bug("bind factory refused in-stream")` (surfaced in-stream, mirroring
CONNECT's dial shape); accept-stage failure → reply#2's mapped code,
then `Bug("bind accept refused in-stream")`. No phantom listeners, no
phantom data planes.
- **Establishment bound**: structurally satisfied — the quiet wait
runs inside the pump handler's spawned task, which the alkcall open
wrapper never bounds (the establisher's 10s bound is pre-handler);
the harness round-trip + the substrate-free "parked accept still
answering after a sleep" test pin the shape.
- **Test determinism**: the quiet-wait tests use a parked accept
factory (`std::future::pending`) so the garbage/EOF branch wins
deterministically (an instantly-resolving accept races the garbage
branch — the client-gone test's owner flag witnesses the future's
drop).
- **Harness additions**: `bind_scoped_identity()` (both scopes — a
session needs the open gate AND the command gate) + BIND
integration tests: round-trip over channels, the gate over
channels, both-scopes identity serves, quiet-wait garbage over
channels (the client EOF + R-02 pump-completion shape), the
two-stage failure mapping over channels, the no-factory variant 3
over channels, and the three-command regression guard. The stale
"ASSOCIATE/BIND refuse 0x07" test became ASSOCIATE-only (BIND now
serves under the gate).
- **dev-dependency**: `tracing-subscriber` (dev-only) added for the
over-channels teardown debugging; `init_trace()` helper is used in
two tests (harmless observability, no behavior pin).
## Summary
> To be filled on completion
Implemented the TCP BIND producer path (`src/producer.rs`): the
`socks5:bind` command gate (ADR-006 — resolved from the open-op
identity at the pump handler, passed as `can_bind` to the
substrate-free entry), the phase model (ADR-007 / POC #5 — accept
factory → reply#1 carrying BND.ADDR → the single-shot quiet wait
(two exits; any byte = garbage, EOF = client-gone teardown riding the
accept future's drop) → reply#2 carrying the peer → `pump_bidi`
inline), two-stage RFC-faithful failure mapping (factory → reply#1,
accept-stage → reply#2, no phantom listeners/data planes), and the
no-factory wrapper-aware variant 3 default (documented). 16 unit
tests + 7 harness integration tests cover the happy path, the gate
(0x02 while CONNECT serves), quiet-wait garbage, client-gone
teardown (owner-flag witness), both failure stages, the
establishment-bound independence, and the three-command regression
guard. `cargo test` (96+26+37), clippy `-D warnings` (native +
wasm), fmt, and both wasm checks clean.
+428 -17
View File
@@ -90,6 +90,13 @@ pub fn both_scopes_identity() -> Identity {
}
}
/// The BIND-capable identity the BIND integration tests open with:
/// both scopes (the open gate needs `socks5:open`, the command gate
/// needs `socks5:bind` — a session needs both grants explicitly).
pub fn bind_scoped_identity() -> Identity {
both_scopes_identity()
}
/// The scoped token — resolves to [`consumer_identity`] via
/// [`TestIdProvider`] (the hub-forwarding path's shape).
pub const TEST_AUTH_TOKEN: &str = "test-token-with-socks5-open-scope";
@@ -789,6 +796,13 @@ mod tests {
fn assert_send_sync<T: Send + Sync>() {}
fn assert_send<T: Send>() {}
fn init_trace() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::level_filters::LevelFilter::TRACE)
.with_test_writer()
.try_init();
}
#[test]
fn identities_carry_the_intended_scopes() {
let open = consumer_identity();
@@ -1335,12 +1349,13 @@ mod tests {
assert_eq!(code, consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED);
}
/// The ASSOCIATE/BIND dispatch arms refuse RFC-faithfully
/// The ASSOCIATE dispatch arm refuses RFC-faithfully
/// (`CommandNotSupported` 0x07 — the pre-vertical posture; the
/// producer-associate/bind tasks replace them) while CONNECT
/// still serves on the same registration (the regression guard).
/// producer-associate task replaces it) while CONNECT still
/// serves on the same registration. BIND now serves under the
/// gate — its integration tests live below.
#[tokio::test]
async fn producer_associate_and_bind_refuse_0x07_connect_still_serves() {
async fn producer_associate_refuses_0x07_connect_still_serves() {
let topo = wire(echo_dial()).await;
// ASSOCIATE refuses.
@@ -1356,19 +1371,6 @@ mod tests {
assert_eq!(code, consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED);
drop(raw);
// BIND refuses.
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("bind.test".into(), 2121),
)
.await
.expect("request written");
let (code, _addr) = raw.read_reply().await.expect("reply read");
assert_eq!(code, consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED);
drop(raw);
// CONNECT still serves the same registration.
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
let code = raw
@@ -1382,6 +1384,415 @@ mod tests {
assert_eq!(&buf, b"still");
}
/// BIND over channels (the phase model end to end): the accept
/// factory resolves at command dispatch, reply#1 carries the
/// advertised BND.ADDR, reply#2 carries the accepted peer, and
/// the data plane two-pumps raw through the channel (echo both
/// directions).
#[tokio::test]
async fn producer_bind_round_trip_over_channels() {
init_trace();
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(echo_accept()),
},
Some(bind_scoped_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("bind.test".into(), ECHO_BIND_PORT),
)
.await
.expect("request written");
// Reply#1: the advertised BND.ADDR (the harness's static
// in-process address).
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"
);
// Reply#2: the accepted peer (the harness's fixed stand-in).
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");
// Phase 2: raw pass-through through the channel, both
// directions.
raw.stream.write_all(b"ping").await.expect("write");
let mut buf = [0u8; 4];
tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the echo");
assert_eq!(&buf, b"ping");
raw.stream.write_all(b"pong").await.expect("write");
tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the echo");
assert_eq!(&buf, b"pong");
// Half-close teardown ends the session cleanly.
drop(raw);
tokio::time::timeout(std::time::Duration::from_secs(10), pump)
.await
.expect("the data plane pump ends on client EOF")
.expect("join");
}
/// The command gate over channels (ADR-006; POC #5 test 4): an
/// identity holding only `socks5:open` gets `ConnectionNotAllowed`
/// (0x02) on BIND — while CONNECT still serves the same identity
/// (the gate distinguishes ingress from egress).
#[tokio::test]
async fn producer_bind_command_gate_refuses_0x02_connect_unaffected() {
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(echo_accept()),
},
Some(consumer_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
// BIND refused, RFC-faithfully — no phantom listener (the
// accept seam never ran; the session ends).
let (mut raw, pump) = open_channel_and_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);
drop(raw);
drop(pump);
// CONNECT still works for the same identity (egress is not
// implied by the BIND gate's absence — separate scopes).
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
let code = raw
.connect(&TargetAddr::Domain("echo.test".into(), 443))
.await
.expect("connect still allowed");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
raw.stream.write_all(b"egress").await.expect("write");
let mut buf = [0u8; 6];
tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the echo");
assert_eq!(&buf, b"egress");
}
/// The BIND-scope-only identity over channels: `socks5:bind`
/// alone serves BIND (the open gate is `socks5:open` — the
/// registration's identity must carry it; the command gate only
/// adds `socks5:bind` on top of a granted session). The both-
/// scopes identity is the deployment shape that grants both
/// explicitly (ADR-006: never implied).
#[tokio::test]
async fn producer_bind_serves_a_both_scopes_identity() {
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(echo_accept()),
},
Some(both_scopes_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("bind.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);
let (code2, _addr) = raw.read_reply().await.expect("reply#2");
assert_eq!(code2, consts::SOCKS5_REPLY_SUCCEEDED);
raw.stream.write_all(b"scoped").await.expect("write");
let mut buf = [0u8; 6];
tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the echo");
assert_eq!(&buf, b"scoped");
}
/// Quiet-wait garbage over channels: a control byte after
/// reply#1 ends the session with no data plane — the raw client
/// reads EOF (no reply#2, no echo), and the channel pump
/// completes.
#[tokio::test]
async fn producer_bind_quiet_wait_garbage_over_channels() {
init_trace();
// The accept future parks (never resolves) so the garbage
// branch is deterministic.
let parked: BindAcceptFactory = Arc::new(move |_target: TargetAddr| {
Box::pin(async move {
let bind_addr =
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), ECHO_BIND_PORT);
Ok(BindAccept::new(
bind_addr,
std::future::pending::<Result<BindTargetHandle, BindAcceptError>>(),
))
})
});
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(parked),
},
Some(bind_scoped_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("quiet.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);
// Protocol garbage in the quiet wait.
raw.stream.write_all(&[0x05, 0x02]).await.expect("garbage");
// The session ends: EOF (no reply#2, no echo).
let mut buf = [0u8; 10];
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read(&mut buf),
)
.await
.expect("read resolves");
match outcome {
Ok(0) | Err(_) => {}
Ok(n) => panic!("no reply#2 after garbage — got {n} bytes"),
}
// The client vanishes (EOF both ways through the raw pipe);
// the session pump ends (R-02 — the handler tracked the data
// plane to its end).
drop(raw);
tokio::time::timeout(std::time::Duration::from_secs(10), pump)
.await
.expect("the session pump ends on the garbage refusal")
.expect("join");
}
/// The three-command regression guard over channels (POC #5 test
/// 7): one registration, all three commands — CONNECT serves,
/// BIND serves under its gate, ASSOCIATE refuses (its task
/// pending). Fresh channel per session.
#[tokio::test]
async fn producer_three_command_regression_guard() {
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(echo_accept()),
},
Some(both_scopes_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
// CONNECT serves.
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
let code = raw
.connect(&TargetAddr::Domain("echo.test".into(), 443))
.await
.expect("connect");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
raw.stream.write_all(b"c").await.expect("write");
let mut buf = [0u8; 1];
tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.stream.read_exact(&mut buf),
)
.await
.expect("echo timed out")
.expect("read the echo");
assert_eq!(&buf, b"c");
drop(raw);
// BIND serves under the gate.
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("bind.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);
let (code2, _addr) = raw.read_reply().await.expect("reply#2");
assert_eq!(code2, consts::SOCKS5_REPLY_SUCCEEDED);
drop(raw);
// ASSOCIATE still refuses (the producer-associate task is
// pending).
let (mut raw, _pump) = open_channel_and_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_COMMAND_NOT_SUPPORTED);
}
/// BIND two-stage failure mapping over channels: factory failure
/// → reply#1's mapped code; accept-stage failure → reply#2's
/// mapped code, no data plane (POC #5 test 6's shape).
#[tokio::test]
async fn producer_bind_failure_mapping_over_channels() {
use alksocks::egress::BindAcceptFactoryError;
// Factory failure → reply#1 ConnectionRefused (0x05).
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(refusing_accept_factory(BindAcceptFactoryError::Refused(
"no listener".into(),
))),
},
Some(bind_scoped_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("factory.fail".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_REFUSED);
drop(raw);
// Accept-stage failure → reply#2 ConnectionNotAllowed (0x02).
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: Some(refusing_accept(BindAcceptError::NotAllowed(
"policy".into(),
))),
},
Some(bind_scoped_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("accept.fail".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 factory resolved"
);
let (code2, _addr) = raw.read_reply().await.expect("reply#2");
assert_eq!(code2, consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED);
}
/// The no-factory default over channels (ADR-007 variant 3,
/// wrapper-aware): sentinel reply#1 + sentinel reply#2, the full
/// phase model preserved (the quiet wait included).
#[tokio::test]
async fn producer_bind_no_factory_serves_variant_3_over_channels() {
let topo = wire_with(
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: None,
},
Some(bind_scoped_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPBind,
&TargetAddr::Domain("variant3.test".into(), 2121),
)
.await
.expect("request written");
let (code1, addr1) = raw.read_reply().await.expect("reply#1");
assert_eq!(code1, consts::SOCKS5_REPLY_SUCCEEDED);
assert!(
addr1.iter().all(|b| *b == 0),
"reply#1 carries the wrapper-aware sentinel"
);
let (code2, addr2) = raw.read_reply().await.expect("reply#2");
assert_eq!(code2, consts::SOCKS5_REPLY_SUCCEEDED);
assert!(
addr2.iter().all(|b| *b == 0),
"reply#2 carries the wrapper-aware sentinel peer"
);
}
/// Multiple sequential CONNECTs ride one registration: fresh
/// channel per session (one channel per SOCKS5 session,
/// convention 10), each dial seam call gets its parsed target.