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
+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.
|
||||
|
||||
Reference in new issue
Block a user