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:
1 parent
cfacc8a968
commit
048493fefa
4 files changed
+1219
-91
No files matched your search
+2
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user