consumer: BindSession — the reply#2 reader + e2e suite

- BindSession::bind (open → no-auth → BIND → reply#1, capturing
  BND.ADDR) + reply1() (the BindReply1 handle) + await_inbound (the
  POC #5 read_reply2 — hand-read with the forked read_address codec,
  no second codec, ADR-011) returning BindDataPlane (into_inner /
  get_socket_mut + AsyncRead/AsyncWrite forwards)
- Teardown ownership (the CONNECT session's posture): Drop sync-reap +
  structural EOF, close() graceful, teardown-at-birth on in-stream
  refusals, no Clone (compile_fail-asserted)
- Typed error surface: open failures ride SocksSessionError::Open
  (ADR-049 reasons); the command gate's 0x02 and factory-failure
  reply#1 codes surface from bind as Rfc; accept-stage reply#2 codes
  surface at await_inbound
- tests/consumer-bind.rs: 9 e2e tests over the harness (round-trip,
  data-plane accessors, gate refusal, both failure mappings,
  quiet-wait garbage, drop teardown + health, the CONNECT+BIND
  regression guard, the Send probe)

Verification: cargo test (259 incl. the doctest), clippy -D warnings
(native + wasm), fmt, both wasm checks, cargo doc clean.
This commit is contained in:
glm-5.3-flash committed 2026-09-24 00:51:46 +00:00
1 parent 76717f3944
commit 776844e2fb
4 files changed
+870 -16

No files matched your search

+305 -6
View File
@@ -8,9 +8,12 @@
//! `Socks5Stream` impls `AsyncRead + AsyncWrite` itself, so a session
//! over a `BiStream` is the intended use, not a hack), and after the
//! reply presents the raw byte pipe (0 B framing — the session
//! reads/writes the target's bytes directly). The ASSOCIATE and BIND
//! sessions land with their tasks (`core-consumer-associate` /
//! `core-consumer-bind`).
//! reads/writes the target's bytes directly). [`BindSession`] is the
//! BIND session: the reply#2 reader (consumer.md §The BIND client,
//! ADR-007) — reply#1 rides the forked client `request()`, reply#2 is
//! hand-read with the forked `read_address` codec (no second codec),
//! then the pass-through data plane. The ASSOCIATE session lands with
//! its task (`core-consumer-associate`).
//!
//! Target construction rides [`ToTargetAddr`]: IP-string forms parse
//! to IP targets; the domain form passes through unresolved (ADR-012
@@ -35,13 +38,14 @@
use alkcall::channels::client::ChannelClient;
use alkcall::channels::manager::ChannelManager;
use alkcall::core::types::BiStream;
use tokio::io::{AsyncWriteExt, ReadHalf, WriteHalf};
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
use crate::error::{SocksOpenError, SocksSessionError};
use crate::params::{Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN};
use crate::rfc1928::client::{Config, Socks5Stream};
use crate::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
use crate::rfc1928::Socks5Command;
use crate::rfc1928::consts;
use crate::rfc1928::util::target_addr::{read_address, TargetAddr, ToTargetAddr};
use crate::rfc1928::{ReplyError, Socks5Command, SocksError};
/// A completed SOCKS5 CONNECT session on the consumer side: the
/// adopted channel + the post-reply byte pipe to the target.
@@ -191,6 +195,301 @@ pub type SplitPipe<'s> = (
WriteHalf<&'s mut Socks5Stream<BiStream>>,
);
/// A completed BIND reply#1 (consumer.md §The BIND client, ADR-007's
/// client-side phase reading): the channel is open, reply#1 carried
/// BND.ADDR — the advertised address the app server dials (the one
/// real address on the channels path: the local variant's bound
/// listener; the composed variant's expected far-side address; the
/// wrapper-aware sentinel `0.0.0.0:0` on the in-band default). The
/// next phase is [`Self::await_inbound`].
///
/// The session owns the adopted channel — teardown stays here
/// (reap via the paired `BindSession`; this handle borrows into it).
/// No `Clone` (the compile-assert rides [`BindSession`]).
pub struct BindReply1<'s> {
inner: &'s mut Socks5Stream<BiStream>,
/// Reply#1's BND.ADDR — the advertised address.
pub bind_addr: TargetAddr,
}
impl<'s> BindReply1<'s> {
/// Await reply#2 (the inbound accept) and take the pass-through
/// data plane. Reply#2 is hand-read with the forked `read_address`
/// codec — reply#1's codec applied again, no second codec (POC #5's
/// `read_reply2` sketch; ADR-011's rationale). A non-success
/// reply#2 errors with the mapped RFC reply code (accept-stage
/// failures surface here — no data plane).
pub async fn await_inbound(self) -> Result<BindDataPlane<'s>, SocksSessionError> {
let peer = read_reply2(self.inner).await?;
Ok(BindDataPlane {
inner: self.inner,
peer,
})
}
/// The raw stream (the front-door bridge seam: a raw pipe to a
/// vanilla client, pre-reply#2).
pub fn raw_mut(&mut self) -> &mut Socks5Stream<BiStream> {
self.inner
}
}
/// The post-reply#2 data plane (the `BindDataPlane`-shaped pass
/// through): the proxied connection to the accepted inbound peer, 0 B
/// framing — reads/writes ride the channel directly. Accessors:
/// [`Self::into_inner`] (owned) / [`Self::get_socket_mut`] (borrowed;
/// the POC shape). The teardown owner is the [`BindSession`] the
/// `into_inner`/`get_socket_mut` borrows came from — drop it (or
/// `close` it) when done; dropping the data plane EOFs the pipe
/// structurally.
pub struct BindDataPlane<'s> {
inner: &'s mut Socks5Stream<BiStream>,
/// Reply#2's peer address (the RFC's "the address of the incoming
/// connection" — the attribution surface, in-band).
pub peer: TargetAddr,
}
impl<'s> BindDataPlane<'s> {
/// Take the underlying [`Socks5Stream<BiStream>`] out of the data
/// plane (the owned pipe; the forked client type). The session
/// rides along still owning the teardown (`BindSession`'s
/// `close`/`Drop`).
pub fn into_inner(self) -> &'s mut Socks5Stream<BiStream> {
self.inner
}
/// The underlying stream (the forked client type's socket
/// accessor, borrowed — the POC's `get_socket_mut` shape).
pub fn get_socket_mut(&mut self) -> &mut BiStream {
self.inner.get_socket_mut()
}
}
impl tokio::io::AsyncRead for BindDataPlane<'_> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let inner = &mut *self.inner;
std::pin::Pin::new(inner).poll_read(cx, buf)
}
}
impl tokio::io::AsyncWrite for BindDataPlane<'_> {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
let inner = &mut *self.inner;
std::pin::Pin::new(inner).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let inner = &mut *self.inner;
std::pin::Pin::new(inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let inner = &mut *self.inner;
std::pin::Pin::new(inner).poll_shutdown(cx)
}
}
/// The BIND consumer session (the `bind()` entry namespace — the POC
/// #5 shape): opens the channel, negotiates, sends BIND, reads
/// reply#1 (`bind`), then awaits reply#2 + the data plane
/// (`await_inbound`). The session owns the adopted channel: on Drop
/// it reaps the entry (the CONNECT session's teardown posture). It
/// does **not** implement `Clone`:
///
/// ```compile_fail
/// use alksocks::consumer::BindSession;
/// fn assert_clone<T: Clone>() {}
/// assert_clone::<BindSession>();
/// ```
///
/// Error surface: a refused BIND (the command gate's 0x02) or a
/// factory failure's reply#1 code surfaces from `bind` as
/// [`SocksSessionError::Rfc`] (the adopted channel is torn down
/// before the error returns — teardown-at-birth, the phantom-channel
/// property); accept-stage failures surface at `await_inbound` as
/// reply#2's mapped code ([`SocksSessionError::Rfc`]) — the channel
/// is still open there (the listener existed; the session lives until
/// the app closes it).
pub struct BindSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
/// Reply#1's BND.ADDR — captured at `bind` (the forked client's
/// `request()` returns it once; the session carries it for the
/// app server's out-of-band dial).
bind_addr: Option<TargetAddr>,
}
impl std::fmt::Debug for BindSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BindSession")
.field("channel_id", &self.channel_id)
.field("awaiting_inbound", &self.inner.is_some())
.field("bind_addr", &self.bind_addr)
.finish()
}
}
impl BindSession {
/// BIND through a produced SOCKS5 resource: opens the channel
/// (`open_channel(OP_SOCKS5_OPEN, {}, SOCKS5_ALPN)` — the trivial
/// params, ADR-001), drives the no-auth handshake and the BIND
/// request in-stream over the channel's `BiStream`, and reads
/// reply#1 (carrying BND.ADDR — the forked client `request()`'s
/// normal path). The returned session owns the channel; the
/// advertised address is [`BindReply1::bind_addr`].
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused BIND — the command gate's `ConnectionNotAllowed` 0x02
/// or a factory failure's mapped reply#1 code — surfaces the RFC
/// reply code through [`SocksSessionError::Rfc`], and the adopted
/// channel is torn down before the error returns (no phantom
/// channel).
pub async fn bind(
client: &ChannelClient,
target: impl ToTargetAddr,
) -> Result<Self, SocksSessionError> {
let target: TargetAddr = target.to_target_addr()?;
let (channel_id, send, recv) = client
.open_channel(OP_SOCKS5_OPEN, Socks5Params::as_json(), SOCKS5_ALPN)
.await
.map_err(SocksOpenError::from)?;
let bidi = BiStream::from_joined(recv, send);
let negotiated = async {
let mut inner = Socks5Stream::use_stream(bidi, None, Config::default()).await?;
let bind_addr = inner.request(Socks5Command::TCPBind, target).await?;
Result::<(TargetAddr, Socks5Stream<BiStream>), SocksSessionError>::Ok((
bind_addr, inner,
))
};
match negotiated.await {
Ok((bind_addr, inner)) => Ok(Self {
channel_id,
manager: Some(client.manager().clone()),
inner: Some(inner),
bind_addr: Some(bind_addr),
}),
Err(err) => {
let _ = client.manager().teardown_channel(channel_id);
Err(err)
}
}
}
/// The adopted channel's id (observability + correlation; one
/// session = one channel).
pub fn channel_id(&self) -> u32 {
self.channel_id
}
/// Reply#1's view: the advertised BND.ADDR (the app server dials
/// it out-of-band — the RFC's original role for the address). The
/// reply#2 reader is [`BindReply1::await_inbound`]; the returned
/// handle borrows the session (teardown stays owned).
pub fn reply1(&mut self) -> Option<BindReply1<'_>> {
let inner = self.inner.as_mut()?;
Some(BindReply1 {
inner,
bind_addr: self.bind_addr.clone()?,
})
}
/// Await the inbound accept (reply#2) and take the pass-through
/// data plane — [`BindReply1::await_inbound`] reached through the
/// session (the borrow rides the session; teardown stays owned).
/// `None` after a taken plane (the spent-session posture).
pub async fn await_inbound(&mut self) -> Result<Option<BindDataPlane<'_>>, SocksSessionError> {
let Some(inner) = self.inner.as_mut() else {
return Ok(None);
};
let peer = read_reply2(inner).await?;
Ok(Some(BindDataPlane { inner, peer }))
}
/// The pass-through pipe as split read/write halves (0 B framing
/// — raw bytes to/from the accepted peer). Valid both in the
/// reply#1 phase (pre-reply#2 bytes ride the same channel — the
/// front-door bridge seam) and post-[`Self::await_inbound`] (the
/// data plane proper). `None` after a graceful `close` (the pipe
/// is dropped with the reap).
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
let inner = self.inner.as_mut()?;
Some(tokio::io::split(inner))
}
/// Tear the session down gracefully: half-close the pipe (the EOF
/// sentinel reaches the producer's pump), then reap the adopted
/// channel entry. Returns whether the entry existed.
pub async fn close(mut self) -> bool {
if let Some(inner) = self.inner.as_mut() {
let _ = inner.shutdown().await;
}
self.inner.take();
self.reap()
}
fn reap(&mut self) -> bool {
self.manager
.take()
.map(|m| m.teardown_channel(self.channel_id).is_ok())
.unwrap_or(false)
}
}
impl Drop for BindSession {
/// Sync-reap the adopted channel entry (`teardown_channel` is
/// sync — best-effort; `Drop` cannot await) and drop the pipe
/// (the mux's implicit-EOF sentinel EOFs the producer's pump).
/// Dropping without `close` never leaks the channel entry.
fn drop(&mut self) {
self.inner.take();
let _ = self.reap();
}
}
/// Read reply#2 off the raw stream (the RFC's second reply: VER, REP,
/// RSV, ATYP, addr, port — the POC #5 `read_reply2` sketch): the 4-byte
/// head, the version pin, the reply code mapped through the forked
/// `ReplyError::from_u8`, then the address via the forked public
/// `read_address` codec — reply#1's codec applied again, **no second
/// codec** (ADR-011's rationale).
async fn read_reply2<T>(inner: &mut Socks5Stream<T>) -> Result<TargetAddr, SocksSessionError>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let socket = inner.get_socket_mut();
let mut header = [0u8; 4];
socket.read_exact(&mut header).await?;
let [version, reply, _rsv, atyp] = header;
if version != consts::SOCKS5_VERSION {
return Err(SocksError::UnsupportedSocksVersion(version).into());
}
if reply != consts::SOCKS5_REPLY_SUCCEEDED {
return Err(SocksError::ReplyError(ReplyError::from_u8(reply)).into());
}
let peer = read_address(socket, atyp)
.await
.map_err(SocksError::AddrError)?;
Ok(peer)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -29,7 +29,7 @@ pub mod params;
pub mod producer;
pub mod wire;
pub use consumer::Socks5ChannelSession;
pub use consumer::{BindDataPlane, BindReply1, BindSession, Socks5ChannelSession};
pub use egress::{
egress_factory, BindAccept, BindAcceptError, BindAcceptFactory, BindAcceptFactoryError,
BindTargetHandle, DialFn, Egress, EgressError, EgressFactory, SocksDialError, TargetHandle,