producer: the session-gate establisher + the command-dispatch pump + CONNECT

The crate's core task (implementation/core-producer-connect): the
producer half of src/producer.rs per producer.md, adapted to the
crate's seam types and the alkcall 0.8.0 establishment surface.

- The session-gate establisher (`socks5_session_establisher`):
  validates/records the opener identity (the CF-005/CF-006 seam,
  ADR-005) via the optional `identity_witness` probe (the alktunnels
  posture); returns `Establishment::default()` — no plan payload
  (the dial happens post-establishment; POC #1's wire note).
- The pump handler (`make_socks5_pump_handler`): `accept_bi()` →
  the forked typestate over the `BiStream` (`accept_no_auth →
  read_command`) → dispatch. CONNECT: the dial seam (`DialFn`,
  ADR-004) at the command-read interception point; success:
  `reply_success(sentinel)` then `pump_bidi` awaited inline (R-02 —
  the JoinHandle tracks the data-plane lifetime). Refusal:
  `reply_error` with the mapped RFC code — no phantom data plane.
- The ASSOCIATE/BIND arms land as RFC-faithful refusals
  (`CommandNotSupported`) — the pre-vertical posture (ADR-008); the
  dispatch skeleton + registration signature carry the seam
  parameters from day one so the associate/bind tasks swap the arms
  without touching the registration shape.
- `register_socks5_openable`: spec + establisher + pump via
  `register_openable_with_establisher` (`timeout: None` = the 10s
  default), scope-gated `socks5:open`. Missing callbacks = those
  commands refuse RFC-faithfully, never a panic.
- `serve_socks5_over<T>`: the substrate-free protocol entry (the
  POC shape), directly testable without channels; the builder
  (`Socks5Producer::builder()`) is the spec'd assembly shape.
- Tests: 11 in-module (substrate-free duplex-driven — round-trip +
  half-close teardown, refusal mapping per variant, missing-dial
  refusal, ASSOCIATE/BIND arms, dispatch guard, establisher witness,
  builder shape) + 5 harness integration (end-to-end CONNECT echo
  with the identity witness, dial refusal 0x05 with no phantom data
  plane, scope-class refusal 0x02, ASSOCIATE/BIND refusals with
  CONNECT still serving, repeated CONNECTs on one registration).
  The harness retargeted to the real producer (`RegisterMode::
  Producer`); the minimal stand-in is a harness-internal helper.

Verification: cargo test 87+19+37 green; clippy --all-targets -D
warnings clean; fmt clean; wasm check + wasm clippy clean; cargo
doc clean; --all-features green.
This commit is contained in:
glm-5.3-flash committed 2026-09-23 19:39:08 +00:00
1 parent f8773521b9
commit cfacc8a968
4 files changed
+1086 -15

No files matched your search

+4
View File
@@ -41,4 +41,8 @@ pub use params::{
establishment_reason, socks5_open_spec, Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN,
SOCKS5_BIND_SCOPE, SOCKS5_OPEN_SCOPE,
};
pub use producer::{
make_socks5_pump_handler, register_socks5_openable, serve_socks5_over,
socks5_session_establisher, IdentityWitness, Socks5Producer, Socks5ProducerBuilder,
};
pub use wire::{addr_encoding_len, frame, DatagramReader, WireError, MAX_INNER_LEN};
+773 -2
View File
@@ -2,5 +2,776 @@
//! session-gate establisher, the command-dispatch pump handler
//! (typestate driving + the three seams), teardown.
//!
//! Stub: filled by the `implementation/core-producer-*` tasks
//! (CONNECT first).
//! Layering (producer.md, the POC-validated shape): the establisher
//! is the **session gate** (`Establishment::default()` — the target
//! is unknown at open time; nothing to thread establisher→handler,
//! POC #1's wire note), the open handler is the **RFC state machine +
//! command dispatch** (the forked typestate, ADR-002), the I/O planes
//! are the **three seams** (dial / egress / accept, ADR-004).
//!
//! ## The establisher
//!
//! [`socks5_session_establisher`] validates/records the opener
//! identity (the CF-005/CF-006 seam — ADR-005: the producer side is
//! identity-only; authorization already happened at the open gate)
//! and applies session-level policy. It returns
//! `Establishment::default()` — no plan payload: the dial happens
//! post-establishment, in-stream at command dispatch, so there is
//! nothing to thread establisher→handler. The optional
//! `identity_witness` probe seam (the alktunnels posture) records
//! the per-call opener identity for tests.
//!
//! A channels-level refusal is the registry ACL gate's job (it runs
//! before the establisher — `socks5:open`, ADR-006; the scope-less
//! identity fails closed with `FORBIDDEN`, no phantom channel). The
//! establisher itself never refuses in this task's scope.
//!
//! ## The pump handler + dispatch
//!
//! [`make_socks5_pump_handler`] accepts the channel's `BiStream`
//! (`accept_bi` — the yield-once contract) and drives the forked
//! typestate over it (`accept_no_auth → read_command`, ADR-005), then
//! dispatches:
//!
//! - **CONNECT** → the dial seam ([`DialFn`], ADR-004) at the
//! command-read interception point; success: `reply_success` then
//! `pump_bidi` **awaited inline** (R-02 — the returned `JoinHandle`
//! tracks the data-plane lifetime; early return = teardown-at-
//! 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.
//!
//! The handler is substrate-agnostic by construction — it sees boxed
//! halves, never a socket type (AGENTS.md convention 7; the
//! inversion point is the seam closure, ADR-004).
//!
//! ## `serve_socks5_over<T>`
//!
//! [`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).
//!
//! ## Registration
//!
//! [`register_socks5_openable`] wires spec + establisher + pump via
//! `ChannelCore::register_openable_with_establisher` (`timeout: None`
//! = the 10s default bound — ADR-049 §2), scope-gated `socks5:open`
//! (the registry runs the ACL before the establisher). Missing
//! callbacks = those commands refuse RFC-faithfully
//! (`CommandNotSupported`-class), never a panic (ADR-008). The
//! builder ([`Socks5Producer::builder`]) is the spec'd assembly
//! shape (producer.md §Wiring); the post-hoc per-connection shape
//! (the harness's W2) rides the same registration.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use alkcall::channels::operations::{ChannelCore, Establishment, OpenEstablisher, OpenHandler};
use alkcall::channels::pump::pump_bidi;
use alkcall::core::auth::AuthContext;
use alkcall::core::types::Connection;
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::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::{ReplyError, Socks5Command, SocksError};
pub use crate::params::{OP_SOCKS5_OPEN, SOCKS5_ALPN, SOCKS5_OPEN_SCOPE};
/// The sentinel `BND.ADDR` the in-band reply carries (ADR-003): pure
/// RFC-compatibility surface — the relay handle IS the channel;
/// wrapper-aware clients never sendto() it (the datagram stage is
/// in-band) and never bind() it (the accept variants own the inbound
/// surface). `127.0.0.1:0` per wire.md §UDP ASSOCIATE.
pub(crate) fn sentinel_reply_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 0)
}
/// What the establisher's per-call auth carried (the CF-006 probe):
/// the opener identity id, `None` until an open runs. The optional
/// `identity_witness` seam (the alktunnels posture) for tests.
pub type IdentityWitness = Arc<tokio::sync::Mutex<Option<String>>>;
/// The session-gate establisher: the open op's awaited phase. For the
/// SOCKS5 op it records the opener identity (the CF-005/CF-006 seam
/// — ADR-005; per-call `auth` is the dispatch-resolved identity
/// overlaid onto the install-time context) and applies session-level
/// policy. A channels-level refusal is the registry ACL gate's job —
/// it runs before this ever runs (ADR-006). The RFC handshake is
/// in-stream, after this. Returns `Establishment::default()` — no
/// plan needed (the dial is in-stream, in the pump handler; POC #1's
/// wire note).
pub fn socks5_session_establisher(witness: Option<IdentityWitness>) -> OpenEstablisher {
Arc::new(move |_input: Value, auth: AuthContext| {
let witness = witness.clone();
Box::pin(async move {
if let Some(witness) = witness {
*witness.lock().await = auth.identity.as_ref().map(|i| i.id.clone());
}
Ok(Establishment::default())
})
})
}
/// 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
/// 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 =
/// teardown-at-birth).
pub fn make_socks5_pump_handler(
dial: Option<DialFn>,
egress: Option<EgressFactory>,
accept: Option<BindAcceptFactory>,
) -> OpenHandler {
Arc::new(
move |_input: Value,
_plan: Option<alkcall::channels::operations::ChannelPlan>,
conn: Connection,
_auth: AuthContext| {
let dial = dial.clone();
let egress = egress.clone();
let accept = accept.clone();
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 {
tracing::warn!("socks5 session ended: {err:#}");
}
})
},
)
}
/// The RFC 1928 server conversation over any `T` — the substrate-free
/// protocol layer (the POC shape). `T` is the channel `BiStream`, a
/// `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.
pub async fn serve_socks5_over<T>(
stream: T,
dial: Option<DialFn>,
egress: Option<EgressFactory>,
_accept: Option<BindAcceptFactory>,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
let (proto, cmd, target) = Socks5ServerProtocol::accept_no_auth(stream)
.await?
.read_command()
.await?;
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,
}
}
/// CONNECT: dial at the command-read interception point (ADR-004),
/// reply, then the pass-through data plane (`pump_bidi`, ADR-050's
/// two-pump shape, awaited inline — R-02). Refusal: `reply_error`
/// with the mapped RFC code — no phantom data plane.
async fn serve_connect<T>(
proto: Socks5ServerProtocol<T, crate::rfc1928::server::states::CommandRead>,
target: TargetAddr,
dial: Option<DialFn>,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
let Some(dial) = dial else {
proto.reply_error(&ReplyError::CommandNotSupported).await?;
return Err(SocksError::ServerError(SocksServerError::UnknownCommand(
Socks5Command::TCPConnect.as_u8(),
)));
};
let handle = match dial(target).await {
Ok(handle) => handle,
Err(err) => {
proto.reply_error(&err.to_reply_error()).await?;
return Err(SocksError::ServerError(SocksServerError::Bug(
"dial refused in-stream",
)));
}
};
let inner = proto.reply_success(sentinel_reply_addr()).await?;
pump_bidi(inner, handle.read, handle.write).await;
Ok(())
}
/// UDP ASSOCIATE: RFC-faithful refusal in this task's scope — the
/// datagram stage lands with the producer-associate task (the
/// pre-vertical posture; ADR-008's missing-callback class).
async fn serve_udp_associate<T>(
proto: Socks5ServerProtocol<T, crate::rfc1928::server::states::CommandRead>,
egress: Option<EgressFactory>,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
let _ = egress;
proto.reply_error(&ReplyError::CommandNotSupported).await?;
Err(SocksError::ServerError(SocksServerError::UnknownCommand(
Socks5Command::UDPAssociate.as_u8(),
)))
}
/// 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>(
proto: Socks5ServerProtocol<T, crate::rfc1928::server::states::CommandRead>,
) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
proto.reply_error(&ReplyError::CommandNotSupported).await?;
Err(SocksError::ServerError(SocksServerError::UnknownCommand(
Socks5Command::TCPBind.as_u8(),
)))
}
/// Register the SOCKS5 open op on a `ChannelCore` + the session's
/// dispatch registry: spec + establisher + pump + injected seams,
/// via `ChannelCore::register_openable_with_establisher` (`timeout:
/// None` = the 10s default bound, ADR-049 §2). Scope-gated
/// `socks5:open` (the registry runs the ACL before the establisher —
/// ADR-006). Missing callbacks = those commands refuse
/// RFC-faithfully (`CommandNotSupported`-class), never a panic
/// (ADR-008).
pub fn register_socks5_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: Option<DialFn>,
egress: Option<EgressFactory>,
accept: Option<BindAcceptFactory>,
witness: Option<IdentityWitness>,
) -> Result<(), String> {
core.register_openable_with_establisher(
socks5_open_spec(),
Some(socks5_session_establisher(witness)),
make_socks5_pump_handler(dial, egress, accept),
on_registry,
auth,
None,
)
}
/// The producer assembly shape (producer.md §Wiring): the
/// registration's injected seams as a builder — the assembly layer
/// closes over substrate code behind the callbacks; the protocol
/// layer never sees a socket type (ADR-004/ADR-008). Missing
/// callbacks = those commands refuse RFC-faithfully (ADR-008).
#[derive(Clone, Default)]
pub struct Socks5Producer {
dial: Option<DialFn>,
egress: Option<EgressFactory>,
accept: Option<BindAcceptFactory>,
}
impl Socks5Producer {
pub fn builder() -> Socks5ProducerBuilder {
Socks5ProducerBuilder::default()
}
/// The substrate-free protocol entry (the same shape
/// [`serve_socks5_over`] pins) over the builder's seams.
pub async fn serve_socks5_over<T>(&self, stream: T) -> Result<(), SocksError>
where
T: AsyncRead + AsyncWrite + Unpin,
{
serve_socks5_over(
stream,
self.dial.clone(),
self.egress.clone(),
self.accept.clone(),
)
.await
}
/// Register the SOCKS5 open op — the same shape
/// [`register_socks5_openable`] pins, over the builder's seams +
/// the optional identity witness.
pub fn register(
&self,
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
witness: Option<IdentityWitness>,
) -> Result<(), String> {
register_socks5_openable(
core,
on_registry,
auth,
self.dial.clone(),
self.egress.clone(),
self.accept.clone(),
witness,
)
}
}
/// The builder for [`Socks5Producer`].
#[derive(Default)]
pub struct Socks5ProducerBuilder {
dial: Option<DialFn>,
egress: Option<EgressFactory>,
accept: Option<BindAcceptFactory>,
}
impl Socks5ProducerBuilder {
/// The dial seam (required for CONNECT; ADR-004).
pub fn dial(mut self, dial: DialFn) -> Self {
self.dial = Some(dial);
self
}
/// The egress seam (required for ASSOCIATE; ADR-004).
pub fn egress(mut self, egress: EgressFactory) -> Self {
self.egress = Some(egress);
self
}
/// The accept seam (required for BIND; ADR-004).
pub fn accept(mut self, accept: BindAcceptFactory) -> Self {
self.accept = Some(accept);
self
}
pub fn build(self) -> Socks5Producer {
Socks5Producer {
dial: self.dial,
egress: self.egress,
accept: self.accept,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::egress::{SocksDialError, TargetHandle};
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
/// The test dial closure (the harness's echo shape, inline): one
/// duplex per dial; a spawned task echoes the far end. The
/// target's identity is returned to the test through the shared
/// witness so the interception-point shape is pinned.
fn echo_dial(targets: Arc<tokio::sync::Mutex<Vec<TargetAddr>>>) -> DialFn {
Arc::new(move |target: TargetAddr| {
let targets = Arc::clone(&targets);
Box::pin(async move {
targets.lock().await.push(target.clone());
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(TargetHandle {
read: Box::new(read_half) as Box<dyn AsyncRead + Send + Sync + Unpin>,
write: Box::new(write_half) as Box<dyn AsyncWrite + Send + Sync + Unpin>,
})
})
})
}
fn refusing_dial(code: SocksDialError) -> DialFn {
Arc::new(move |_target: TargetAddr| {
let err = match &code {
SocksDialError::Refused(m) => SocksDialError::Refused(m.clone()),
SocksDialError::NetworkUnreachable(m) => {
SocksDialError::NetworkUnreachable(m.clone())
}
SocksDialError::HostUnreachable(m) => SocksDialError::HostUnreachable(m.clone()),
SocksDialError::NotAllowed(m) => SocksDialError::NotAllowed(m.clone()),
SocksDialError::General(m) => SocksDialError::General(m.clone()),
};
Box::pin(async move { Err(err) })
})
}
/// Drive a raw RFC 1928 client against [`serve_socks5_over`] over
/// a duplex (the differential-suite pattern: the producer
/// testable without channels).
async fn raw_connect(
dial: DialFn,
) -> (
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, Some(dial), None, None));
(handle, client_end)
}
async fn greet(client: &mut DuplexStream) {
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
let mut chosen = [0u8; 2];
client.read_exact(&mut chosen).await.unwrap();
assert_eq!(chosen, [0x05, 0x00]);
}
async fn write_request(client: &mut DuplexStream, cmd: &Socks5Command, target: &TargetAddr) {
let addr = target.to_be_bytes().unwrap();
let mut req = vec![0x05, cmd.as_u8(), 0x00];
req.extend_from_slice(&addr);
client.write_all(&req).await.unwrap();
}
async fn read_reply(client: &mut DuplexStream) -> (u8, [u8; 6]) {
let mut head = [0u8; 4];
client.read_exact(&mut head).await.unwrap();
assert_eq!(head[0], 0x05);
assert_eq!(head[3], 0x01, "tests use v4 BND.ADDR");
let mut addr = [0u8; 6];
client.read_exact(&mut addr).await.unwrap();
(head[1], addr)
}
/// The full CONNECT conversation: dial is invoked at the
/// command-read interception point, success reply, then the
/// two-pump echo round-trip through the dialed target.
#[tokio::test]
async fn connect_round_trip_through_pump_bidi() {
let targets = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let (handle, mut client) = raw_connect(echo_dial(Arc::clone(&targets))).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("echo.test".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00, "the dial resolved");
// The data plane is raw pass-through: echo through the dialed
// target, both directions.
client.write_all(b"ping").await.unwrap();
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
// The dial seam saw the parsed RFC target (the interception
// point — domain form passes through unresolved, ADR-012).
assert_eq!(
targets.lock().await.last(),
Some(&TargetAddr::Domain("echo.test".into(), 443))
);
// 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");
}
/// A dial refusal surfaces as the mapped RFC reply code in the
/// reply — no phantom data plane (nothing is dialed, no bytes
/// flow after the refusal).
#[tokio::test]
async fn dial_refusal_maps_to_reply_code_with_no_data_plane() {
let dial = refusing_dial(SocksDialError::Refused("target down".into()));
let (handle, mut client) = raw_connect(dial).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("down.test".into(), 80),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x05, "dial refusal → ConnectionRefused");
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("the session surfaces the refusal in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::Bug("dial refused in-stream"))
));
}
/// Every dial error variant maps to its documented RFC reply
/// code through the full conversation (the in-stream refusal
/// path, not just the mapping table).
#[tokio::test]
async fn dial_error_variants_map_to_their_reply_codes() {
for (err, code) in [
(SocksDialError::Refused("x".into()), 0x05),
(SocksDialError::NetworkUnreachable("x".into()), 0x03),
(SocksDialError::HostUnreachable("x".into()), 0x04),
(SocksDialError::NotAllowed("x".into()), 0x02),
(SocksDialError::General("x".into()), 0x01),
] {
let (handle, mut client) = raw_connect(refusing_dial(err)).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 1], 80))),
)
.await;
let (got, _addr) = read_reply(&mut client).await;
assert_eq!(got, code, "the mapped code rides the reply");
drop(client);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join");
}
}
/// A missing dial callback refuses CONNECT RFC-faithfully
/// (`CommandNotSupported` — ADR-008's posture: an unprovided
/// capability, never a panic).
#[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 mut client = client_end;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("no.dial".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x07, "missing dial → CommandNotSupported");
drop(client);
let err = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("session ends")
.expect("join")
.expect_err("the refusal is surfaced in-stream");
assert!(matches!(
err,
SocksError::ServerError(SocksServerError::UnknownCommand(_))
));
}
/// 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.
#[tokio::test]
async fn associate_and_bind_arms_refuse_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(_))
));
}
}
/// The dispatch skeleton + the two-pump shape coexist: CONNECT
/// serves, ASSOCIATE/BIND refuse — one `serve_socks5_over`, all
/// three commands (the early 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())));
let (handle, mut client) = raw_connect(Arc::clone(&dial)).await;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("ok.test".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00);
client.write_all(b"ok").await.unwrap();
let mut buf = [0u8; 2];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ok");
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).
#[tokio::test]
async fn pump_handler_tracks_the_data_plane_to_completion() {
let targets = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let dial = echo_dial(Arc::clone(&targets));
let handler = make_socks5_pump_handler(Some(Arc::clone(&dial)), None, None);
// A fake alkcall `Connection` is not constructible outside
// alkcall; drive the substrate-free path instead — the
// handler's task body is exactly `accept_bi` +
// `serve_socks5_over` (the shape this test pins is the
// inline-await). The full handler-over-channels shape is the
// 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 mut client = client_end;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("inline.test".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00);
client.write_all(b"tick").await.unwrap();
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"tick");
drop(client);
let result = tokio::time::timeout(std::time::Duration::from_secs(5), serve)
.await
.expect("the session ends on client EOF — the data plane is tracked")
.expect("join");
result.expect("clean session");
assert_eq!(
targets.lock().await.last(),
Some(&TargetAddr::Domain("inline.test".into(), 443))
);
}
/// The establisher records the opener identity (CF-006) and
/// returns the no-plan establishment (the session gate — the
/// target is unknown at open time, POC #1's wire note).
#[tokio::test]
async fn establisher_records_identity_and_defaults_the_establishment() {
let witness: IdentityWitness = Arc::new(tokio::sync::Mutex::new(None));
let establisher = socks5_session_establisher(Some(Arc::clone(&witness)));
let auth = AuthContext {
identity: Some(alkcall::core::auth::Identity {
id: "opener".to_string(),
scopes: vec![SOCKS5_OPEN_SCOPE.to_string()],
resources: Default::default(),
}),
..AuthContext::anonymous(b"alk/socks5")
};
let establishment = establisher(serde_json::json!({}), auth)
.await
.expect("the session gate passes");
assert!(establishment.plan.is_none(), "no plan payload");
assert_eq!(
witness.lock().await.as_deref(),
Some("opener"),
"the per-call opener identity is recorded"
);
}
/// The builder assembly shape (producer.md §Wiring): seams ride
/// the builder; `serve_socks5_over` over the built producer
/// behaves like the free function.
#[tokio::test]
async fn builder_shape_round_trips() {
let producer = Socks5Producer::builder()
.dial(echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new()))))
.build();
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
let serve = tokio::spawn(async move { producer.serve_socks5_over(server_end).await });
let mut client = client_end;
greet(&mut client).await;
write_request(
&mut client,
&Socks5Command::TCPConnect,
&TargetAddr::Domain("builder.test".into(), 443),
)
.await;
let (code, _addr) = read_reply(&mut client).await;
assert_eq!(code, 0x00);
client.write_all(b"ping").await.unwrap();
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
drop(client);
let result = tokio::time::timeout(std::time::Duration::from_secs(5), serve)
.await
.expect("session ends")
.expect("join");
result.expect("clean");
}
/// The sentinel reply address (the in-band BND.ADDR posture,
/// ADR-003): `127.0.0.1:0` — the RFC-compatibility surface.
#[test]
fn sentinel_reply_addr_shape() {
assert_eq!(sentinel_reply_addr(), SocketAddr::from(([127, 0, 0, 1], 0)));
}
/// The pump handler's seam-closure shape: `Clone`-carried
/// `Option` seams survive the closure move (the registration's
/// shape — the handler is reusable across sessions).
#[test]
fn producer_builder_seams_survive_clone() {
let producer = Socks5Producer::builder()
.dial(echo_dial(Arc::new(tokio::sync::Mutex::new(Vec::new()))))
.build();
let cloned = producer.clone();
let (client_end, server_end) = tokio::io::duplex(64);
drop(client_end);
drop(cloned.serve_socks5_over(server_end));
}
}
+66 -9
View File
@@ -1,7 +1,7 @@
---
id: implementation/core-producer-connect
name: "Core — producer half: establisher + dispatch + registration + CONNECT"
status: pending
status: completed
depends_on: [implementation/core-seam-types, implementation/core-params-errors, implementation/core-test-harness]
scope: moderate
risk: medium
@@ -66,18 +66,18 @@ to the crate's seam types and alkcall 0.8.0):
## Acceptance Criteria
- [ ] Establisher: session-gate shape (`Establishment::default()`,
- [x] Establisher: session-gate shape (`Establishment::default()`,
identity recorded; witness seam optional)
- [ ] Pump handler: typestate-driven dispatch; `pump_bidi` inline
- [x] Pump handler: typestate-driven dispatch; `pump_bidi` inline
for CONNECT; no substrate types leak
- [ ] `register_socks5_openable` wires spec + establisher + pump +
- [x] `register_socks5_openable` wires spec + establisher + pump +
injected seams; missing-callback commands refuse
RFC-faithfully
- [ ] `serve_socks5_over<T>` is substrate-free and directly testable
- [ ] Integration tests: round-trip, refusal mapping (0x05),
- [x] `serve_socks5_over<T>` is substrate-free and directly testable
- [x] Integration tests: round-trip, refusal mapping (0x05),
FORBIDDEN fails closed, identity witness, refusal arms for
ASSOCIATE/BIND, half-close teardown
- [ ] `cargo test` green; clippy/fmt clean; both wasm checks clean
- [x] `cargo test` green; clippy/fmt clean; both wasm checks clean
## References
@@ -95,8 +95,65 @@ to the crate's seam types and alkcall 0.8.0):
## Notes
> To be filled by implementation agent
> Implementation notes (2026-09-23):
>
> - `Socks5ProducerBuilder` was added beyond the deliverable list —
> it is the spec'd assembly shape (producer.md §Wiring, the
> `Socks5Producer::builder()` sketch); `register_socks5_openable`
> stays as the explicit per-registration form. Both ride the same
> `register_openable_with_establisher` call; the builder's
> `register`/`serve_socks5_over` delegate to them verbatim.
> - The `establisher` accepts `Option<IdentityWitness>` (the
> alktunnels `*_with_witness` posture) rather than a required
> witness — the witness is a test seam, not a protocol surface;
> `register_socks5_openable` threads it through.
> - The ASSOCIATE arm carries the `EgressFactory` parameter (and the
> BIND arm the `BindAcceptFactory`) in the registration signature
> from day one — the associate/bind tasks replace the refusal arms
> without touching the registration shape (no churn for the
> callers). Until those tasks land, the seams are accepted and
> their commands refuse `CommandNotSupported` (the pre-vertical
> posture, ADR-008's missing-callback class) — tested.
> - CONNECT's refusal error shape: dial-seam refusal returns
> `SocksServerError::Bug("dial refused in-stream")` after
> `reply_error` (the refusal was already RFC-reported; the error
> return is the session-end signal, not a second wire message).
> A missing dial callback refuses `CommandNotSupported` via
> `SocksServerError::UnknownCommand(cmd)`.
## Summary
> To be filled on completion
> Completed 2026-09-23 (commit on `main`). `src/producer.rs` filled:
> the session-gate establisher ([`socks5_session_establisher`] —
> CF-005/CF-006 witness seam, `Establishment::default()` per POC #1's
> no-plan wire note), the command-dispatch pump handler
> ([`make_socks5_pump_handler`] — `accept_bi` → typestate
> (`accept_no_auth → read_command`) → dispatch), the CONNECT data
> plane (`DialFn` at the command-read interception point →
> `reply_success(sentinel)` → `pump_bidi` awaited inline, R-02),
> `register_socks5_openable` (spec + establisher + pump via
> `register_openable_with_establisher`, `timeout: None` = the 10s
> default, `socks5:open`-gated), `serve_socks5_over<T>` (the
> substrate-free entry), and the builder assembly shape.
>
> Dispatch arms: CONNECT serves; ASSOCIATE/BIND refuse
> `CommandNotSupported` (the pre-vertical posture — the skeleton
> exists so the regression guard is testable early; the associate/
> bind tasks replace the arms).
>
> Tests: 11 in-module (substrate-free, duplex-driven: round-trip +
> half-close teardown, refusal mapping per variant 0x05/0x03/0x04/
> 0x02/0x01, missing-dial refusal, ASSOCIATE/BIND arms, dispatch
> guard, establisher witness + no-plan, builder shape, sentinel
> shape) + 5 harness integration (end-to-end CONNECT echo through
> channels with the identity witness, dial refusal 0x05 with no
> phantom data plane, scope-class refusal 0x02, ASSOCIATE/BIND
> refusals with CONNECT still serving — the one-registration
> regression guard, repeated CONNECTs on one registration). The
> harness's `RegisterMode` gained the real-producer mode; the
> minimal CONNECT stand-in is retained only as a harness-internal
> helper.
>
> Verification: `cargo test` 87+19+37 green; clippy `--all-targets`
> clean; fmt clean; both wasm checks clean; `cargo doc` clean;
> `--all-features` green.
+243 -4
View File
@@ -421,6 +421,17 @@ pub enum RegisterMode {
/// the given dial, with an identity-witnessing establisher (the
/// CF-006 seam probe rides the same topology).
MinimalConnect(DialFn),
/// The real producer registration
/// ([`alksocks::producer::register_socks5_openable`]): the
/// session-gate establisher + the command-dispatch pump handler
/// over the injected seams (dial required for CONNECT; the
/// egress/accept arms refuse RFC-faithfully until their tasks
/// land).
Producer {
dial: DialFn,
egress: Option<EgressFactory>,
accept: Option<BindAcceptFactory>,
},
/// No socks5 openable — only the generic channel ops (the
/// unknown-op / registry-gate probe's shape).
NoSocks5,
@@ -618,6 +629,27 @@ pub async fn wire_with(
)
.expect("register socks5 openable (minimal CONNECT stand-in)");
}
if let RegisterMode::Producer {
dial,
egress,
accept,
} = &mode
{
let core = ChannelCore::new(
producer_client.manager().clone(),
alkcall::channels::policy::default_policy(),
);
alksocks::producer::register_socks5_openable(
&core,
&producer_op_registry,
AuthContext::anonymous(b"alk/socks5"),
Some(Arc::clone(dial)),
egress.clone(),
accept.clone(),
Some(Arc::clone(&identity_witness)),
)
.expect("register socks5 openable (the real producer)");
}
// --- consumer pieces (captured from the hook) -----------------------
let consumer_call = consumer_call_rx.recv().await.expect("consumer call conn");
@@ -634,11 +666,15 @@ pub async fn wire_with(
}
/// The default topology: the consumer's transport identity is the
/// primary caller-identity path; the producer registers the minimal
/// CONNECT stand-in over the given dial.
/// primary caller-identity path; the producer registers the real
/// CONNECT path over the given dial.
pub async fn wire(dial: DialFn) -> Topology {
wire_with(
RegisterMode::MinimalConnect(dial),
RegisterMode::Producer {
dial,
egress: None,
accept: None,
},
Some(consumer_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
@@ -1087,7 +1123,11 @@ mod tests {
resources: std::collections::HashMap::new(),
};
let topo = wire_with(
RegisterMode::MinimalConnect(echo_dial()),
RegisterMode::Producer {
dial: echo_dial(),
egress: None,
accept: None,
},
Some(bare),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
@@ -1163,4 +1203,203 @@ mod tests {
.expect("adopt");
assert!(topo.consumer_manager.has_channel(channel_id));
}
// -- the real-producer integration tests -----------------------------
/// Open a socks5 channel + adopt it + pump the raw RFC client
/// through it (the smoke test's opening shape, shared by the
/// producer integration tests).
async fn open_channel_and_pump(
topo: &Topology,
) -> (
RawRfcClient<tokio::io::DuplexStream>,
tokio::task::JoinHandle<(u64, u64)>,
) {
let response = topo
.consumer_call
.call(OP_SOCKS5_OPEN, Socks5Params::as_json())
.await;
let out = response.result.expect("the socks5 channel opens");
let channel_id = out
.get("channel_id")
.and_then(|v| v.as_u64())
.expect("the reply carries channel_id") as u32;
let (send, recv) = topo
.consumer_manager
.adopt_channel(channel_id, SOCKS5_ALPN, None)
.await
.expect("adopt the producer-allocated id");
let (stream_peer, stream_self) = tokio::io::duplex(64 * 1024);
let pump = tokio::spawn(pump_bidi(
stream_self,
Box::new(recv) as Box<dyn AsyncRead + Send + Unpin>,
Box::new(send) as Box<dyn AsyncWrite + Send + Unpin>,
));
(RawRfcClient::new(stream_peer), pump)
}
/// The real producer end-to-end (the smoke test through the
/// command-dispatch path, not the minimal stand-in): CONNECT
/// round-trip + echo + the identity witness (CF-006) + half-close
/// teardown (client EOF ends the session cleanly).
#[tokio::test]
async fn producer_connect_echo_witnesses_identity_and_tears_down_cleanly() {
let topo = wire(echo_dial()).await;
let (mut raw, pump) = open_channel_and_pump(&topo).await;
let code = tokio::time::timeout(
std::time::Duration::from_secs(10),
raw.connect(&TargetAddr::Domain("echo.test".into(), 443)),
)
.await
.expect("CONNECT round-trip timed out")
.expect("the raw client completes");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
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");
// The establisher witnessed the per-call opener identity.
assert_eq!(
topo.identity_witness.lock().await.as_deref(),
Some("consumer")
);
// Half-close teardown: client EOF ends the session; the
// channel pump completes (the handler tracked the data plane
// to the end — R-02).
drop(raw);
tokio::time::timeout(std::time::Duration::from_secs(10), pump)
.await
.expect("the data plane pump ends on client EOF")
.expect("join");
}
/// A refusing dial surfaces 0x05 in-stream with no phantom data
/// plane: the reply comes back refused; bytes sent after the
/// refusal are not echoed (nothing was dialed). The channel
/// stays healthy — a fresh open succeeds immediately.
#[tokio::test]
async fn producer_dial_refusal_is_0x05_and_no_phantom_data_plane() {
let topo = wire(refusing_dial(SocksDialError::Refused("target down".into()))).await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
let method = raw.greet().await.expect("greet");
assert_eq!(method, consts::SOCKS5_AUTH_METHOD_NONE);
raw.request(
Socks5Command::TCPConnect,
&TargetAddr::Domain("down.test".into(), 80),
)
.await
.expect("request written");
let (code, _addr) = raw.read_reply().await.expect("reply read");
assert_eq!(code, consts::SOCKS5_REPLY_CONNECTION_REFUSED);
// No phantom data plane: nothing echoes (the refusal is the
// whole conversation; the data plane never existed).
drop(_pump);
// The channel topology is healthy: a fresh CONNECT through a
// working dial succeeds immediately after the refused one.
let topo2 = wire(echo_dial()).await;
let (mut raw2, _pump2) = open_channel_and_pump(&topo2).await;
let code = raw2
.connect(&TargetAddr::Domain("echo.test".into(), 443))
.await
.expect("fresh connect");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
}
/// The scope-class dial refusal (`NotAllowed`) surfaces 0x02
/// (the mapped code — the dial seam's policy refusal path).
#[tokio::test]
async fn producer_scope_class_refusal_is_0x02() {
let topo = wire(refusing_dial(SocksDialError::NotAllowed("policy".into()))).await;
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
raw.greet().await.expect("greet");
raw.request(
Socks5Command::TCPConnect,
&TargetAddr::Domain("policy.test".into(), 80),
)
.await
.expect("request written");
let (code, _addr) = raw.read_reply().await.expect("reply read");
assert_eq!(code, consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED);
}
/// The ASSOCIATE/BIND dispatch arms refuse 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).
#[tokio::test]
async fn producer_associate_and_bind_refuse_0x07_connect_still_serves() {
let topo = wire(echo_dial()).await;
// ASSOCIATE refuses.
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);
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
.connect(&TargetAddr::Domain("echo.test".into(), 443))
.await
.expect("connect");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
raw.stream.write_all(b"still").await.expect("write");
let mut buf = [0u8; 5];
raw.stream.read_exact(&mut buf).await.expect("echo");
assert_eq!(&buf, b"still");
}
/// 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.
#[tokio::test]
async fn producer_serves_repeated_connects_on_one_registration() {
let topo = wire(echo_dial()).await;
for i in 0..3 {
let (mut raw, _pump) = open_channel_and_pump(&topo).await;
let code = raw
.connect(&TargetAddr::Domain(format!("t{i}.test"), 443))
.await
.expect("connect");
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
raw.stream.write_all(b"ping").await.expect("write");
let mut buf = [0u8; 4];
raw.stream.read_exact(&mut buf).await.expect("echo");
assert_eq!(&buf, b"ping");
drop(raw);
}
}
}