consumer: AssociateSession — the framed datagram stage + e2e suite
The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md §The associate session, ADR-003): - `AssociateSession::associate` — open → no-auth → ASSOCIATE → reply; the sentinel BND.ADDR is received (`relay_addr()`, RFC fidelity) and never used for addressing (the handle is the channel). The ASSOCIATE DST.ADDR is the vanilla-mirror unspecified address. - `send_datagram` — frames exactly once via the crate's wire `frame()` (the framing-once mirror; the u16 joint bound enforced at frame time). Takes `impl ToTargetAddr` (ADR-012 posture); a `ToTargetAddr for &TargetAddr` impl was added to the forked codec so a `TargetAddr` value survives across sends. - `recv_datagram` — the incremental `DatagramReader` + batched- datagram pending queue, state persisting across calls (the POC's reader-state finding); `Ok(None)` on channel EOF; fail-loud on a stream end mid-frame and on undecodable frames (`WireError` → the forked `AddrError`/`UdpHeaderError` RFC classes — the consumer mirror of the producer stage's `UndecodableFrame` exit). - `shutdown` — the half-close; the mux's implicit-EOF sentinel reaches the producer's stage as EOF (association lifetime = channel lifetime). `close()`/`Drop` own the teardown (sync reap + structural EOF, the CONNECT/BIND posture); teardown-at-birth on in-stream refusals; no `Clone` (compile-fail asserted). - Typed error surface: open failures are `SocksSessionError::Open` (ADR-049); the missing-egress 0x07 and factory-failure codes surface from `associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8 tests over the harness): round-trips (multiple in-order datagrams, one association, sentinel relay address asserted), the split-datagram persistence test (a 60 KiB datagram reassembling across the call boundary + the pending- queue drain — both halves of the persistence invariant), the empty- payload round-trip (F-2, never EOF), shutdown → a fresh associate succeeds immediately (the POC's test 4), Drop teardown + connection health, the missing-egress refusal (typed 0x07, no phantom channel), the egress-factory failure mapping, and the CONNECT+ASSOCIATE regression guard (interleaved on one registration). Unit tests (4) pin the reader internals over a duplex (`skip_auth`'s handshake-free path). Verification: cargo test 305 (7 suites, all green), clippy --all-targets -D warnings, fmt, cargo doc --no-deps, and both wasm32-unknown-unknown checks clean.
This commit is contained in:
1 parent
776844e2fb
commit
c57451545d
5 files changed
+1095
-18
No files matched your search
+518
-8
@@ -12,32 +12,51 @@
|
||||
//! 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`).
|
||||
//! then the pass-through data plane. [`AssociateSession`] is the UDP
|
||||
//! ASSOCIATE session (POC #2's shape — see its docs).
|
||||
//!
|
||||
//! Target construction rides [`ToTargetAddr`]: IP-string forms parse
|
||||
//! to IP targets; the domain form passes through unresolved (ADR-012
|
||||
//! — resolution is the egress's job, never the protocol's).
|
||||
//!
|
||||
//! [`AssociateSession`] is the UDP ASSOCIATE session (POC #2's shape,
|
||||
//! consumer.md §The associate session, ADR-003): it opens the
|
||||
//! channel, negotiates, sends ASSOCIATE, reads the reply — **receives
|
||||
//! the sentinel `BND.ADDR` and never uses it for addressing** (the
|
||||
//! handle is the channel). The datagram plane is the framed stage:
|
||||
//! `send_datagram` frames once (`[len: u16 BE][addr + data]` — the
|
||||
//! framing-once mirror, ADR-003) and `recv_datagram` decodes
|
||||
//! incrementally with **reader state that persists across calls**
|
||||
//! (mid-frame position + batched-datagram pending queue — a datagram
|
||||
//! split across channel chunks survives a caller-driven receive
|
||||
//! loop; POC #2's finding: the first cut dropped the reader state
|
||||
//! per call, the 2-byte-prefix chunk test exposed it). `shutdown`
|
||||
//! half-closes the stream — the mux's implicit-EOF sentinel reaches
|
||||
//! the producer's stage as EOF and the association ends (association
|
||||
//! lifetime = channel lifetime).
|
||||
//!
|
||||
//! Teardown ownership (alktunnels ADR-005 posture, inherited via
|
||||
//! ADR-004): the session owns the adopted channel entry. On Drop it
|
||||
//! reaps it (`ChannelManager::teardown_channel` — sync, best-effort;
|
||||
//! `Drop` cannot await) and the data plane EOFs structurally (the
|
||||
//! mux's implicit-EOF sentinel on the dropped write half). The
|
||||
//! teardown-at-birth discipline covers the in-stream refusal path:
|
||||
//! a refused CONNECT tears the adopted channel down before the
|
||||
//! a refused ASSOCIATE tears the adopted channel down before the
|
||||
//! error returns — a refused session never leaves a phantom channel.
|
||||
//! The session does **not** implement `Clone` (compile-asserted on
|
||||
//! the struct): aliasing a session would alias its teardown.
|
||||
//! the struct): aliasing a session would alias its teardown and its
|
||||
//! reader state.
|
||||
//!
|
||||
//! Error surface (consumer.md §Error surface): open failures are the
|
||||
//! typed [`SocksOpenError`] reasons (ADR-049 §4 — a refused session
|
||||
//! never yields a phantom channel); a refused target is an RFC reply
|
||||
//! code surfaced through [`SocksSessionError::Rfc`].
|
||||
//! never yields a phantom channel); a refused ASSOCIATE is an RFC
|
||||
//! reply code surfaced through [`SocksSessionError::Rfc`].
|
||||
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::channels::manager::ChannelManager;
|
||||
use alkcall::core::types::BiStream;
|
||||
use bytes::Bytes;
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
|
||||
|
||||
use crate::error::{SocksOpenError, SocksSessionError};
|
||||
@@ -45,7 +64,8 @@ use crate::params::{Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN};
|
||||
use crate::rfc1928::client::{Config, Socks5Stream};
|
||||
use crate::rfc1928::consts;
|
||||
use crate::rfc1928::util::target_addr::{read_address, TargetAddr, ToTargetAddr};
|
||||
use crate::rfc1928::{ReplyError, Socks5Command, SocksError};
|
||||
use crate::rfc1928::{ReplyError, Socks5Command, SocksError, UdpHeaderError};
|
||||
use crate::wire::{frame, DatagramReader, WireError};
|
||||
|
||||
/// A completed SOCKS5 CONNECT session on the consumer side: the
|
||||
/// adopted channel + the post-reply byte pipe to the target.
|
||||
@@ -177,6 +197,302 @@ impl Drop for Socks5ChannelSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md
|
||||
/// §The associate session, ADR-003): the channel is the relay handle
|
||||
/// — the reply's sentinel `BND.ADDR` is received and never used for
|
||||
/// addressing, the datagram plane is the framed stage on the channel
|
||||
/// (`[len: u16 BE][addr + data]`, both directions), and the
|
||||
/// association's lifetime is the channel's.
|
||||
///
|
||||
/// The session is a handle to one channel (one session = one channel
|
||||
/// = one ASSOCIATE, convention 10) and owns its reader state: the
|
||||
/// incremental [`DatagramReader`] (mid-frame position) and the
|
||||
/// pending-queue of batched datagrams both **persist across
|
||||
/// `recv_datagram` calls** — a datagram split across channel chunks
|
||||
/// survives a caller-driven receive loop (POC #2's finding, the
|
||||
/// first cut dropped the reader state per call). It does **not**
|
||||
/// implement `Clone`:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use alksocks::consumer::AssociateSession;
|
||||
/// fn assert_clone<T: Clone>() {}
|
||||
/// assert_clone::<AssociateSession>();
|
||||
/// ```
|
||||
///
|
||||
/// The inner stream is `Option` so `Drop`/`shutdown`/`close` can
|
||||
/// empty the type (the alktunnels `Drop`-impl posture); a spent
|
||||
/// session drops as a teardown-only no-op.
|
||||
pub struct AssociateSession {
|
||||
channel_id: u32,
|
||||
manager: Option<ChannelManager>,
|
||||
inner: Option<Socks5Stream<BiStream>>,
|
||||
/// The incremental length-codec decoder — its mid-frame state
|
||||
/// persists across `recv_datagram` calls (a datagram may be split
|
||||
/// across channel chunks; the same `DatagramReader` the producer
|
||||
/// stage runs).
|
||||
reader: DatagramReader,
|
||||
/// Datagrams decoded beyond the one returned (a chunk may batch
|
||||
/// several datagrams — drained by later `recv_datagram` calls).
|
||||
/// The batched datagrams are stored decoded (`(addr, payload)` —
|
||||
/// the same shape `recv_datagram` returns).
|
||||
pending: std::collections::VecDeque<(TargetAddr, Bytes)>,
|
||||
/// The reply's BND.ADDR — received for RFC fidelity, unused for
|
||||
/// addressing (wrapper-aware clients sendto the channel; ADR-003).
|
||||
relay_addr: Option<TargetAddr>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AssociateSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AssociateSession")
|
||||
.field("channel_id", &self.channel_id)
|
||||
.field("associated", &self.inner.is_some())
|
||||
.field("relay_addr", &self.relay_addr)
|
||||
.field("pending_datagrams", &self.pending.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AssociateSession {
|
||||
/// ASSOCIATE 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
|
||||
/// UDP ASSOCIATE request in-stream over the channel's `BiStream`,
|
||||
/// and reads the reply. The DST.ADDR of the ASSOCIATE command
|
||||
/// mirrors the vanilla shape — the unspecified address (`[::]:0`),
|
||||
/// as the forked `Socks5Datagram` sends. The reply's BND.ADDR is
|
||||
/// the producer's sentinel (`127.0.0.1:0` on the in-band path) —
|
||||
/// captured as [`Self::relay_addr`], never used for addressing
|
||||
/// (ADR-003: the handle is the channel).
|
||||
///
|
||||
/// Error surface: an open failure is [`SocksSessionError::Open`]
|
||||
/// (the typed ADR-049 reasons; no channel was ever returned); a
|
||||
/// refused ASSOCIATE (the missing-egress `CommandNotSupported`
|
||||
/// 0x07 or a factory failure's mapped 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 associate(client: &ChannelClient) -> Result<Self, SocksSessionError> {
|
||||
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 relay_addr = inner
|
||||
.request(
|
||||
Socks5Command::UDPAssociate,
|
||||
TargetAddr::Ip(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0)),
|
||||
)
|
||||
.await?;
|
||||
Result::<(TargetAddr, Socks5Stream<BiStream>), SocksSessionError>::Ok((
|
||||
relay_addr, inner,
|
||||
))
|
||||
};
|
||||
|
||||
match negotiated.await {
|
||||
Ok((relay_addr, inner)) => Ok(Self {
|
||||
channel_id,
|
||||
manager: Some(client.manager().clone()),
|
||||
inner: Some(inner),
|
||||
reader: DatagramReader::new(),
|
||||
pending: std::collections::VecDeque::new(),
|
||||
relay_addr: Some(relay_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
|
||||
}
|
||||
|
||||
/// The reply's sentinel `BND.ADDR` (RFC fidelity: the relay
|
||||
/// address the vanilla shape would sendto; the wrapper-aware
|
||||
/// session never uses it — the handle is the channel, ADR-003).
|
||||
/// `None` after a taken stream (the spent-session posture).
|
||||
pub fn relay_addr(&self) -> Option<TargetAddr> {
|
||||
self.relay_addr.clone()
|
||||
}
|
||||
|
||||
/// Send one datagram to `dst`: the RFC address encoding + the
|
||||
/// payload, length-framed **exactly once** by the wire codec
|
||||
/// ([`frame`] — the framing-once mirror, ADR-003) and written to
|
||||
/// the channel. A zero-length payload is a legal empty datagram
|
||||
/// (the F-2 invariant at payload level). Rejects
|
||||
/// [`SocksSessionError::Rfc`] ([`WireError`]) at frame time —
|
||||
/// the joint bound (`len(addr) + len(payload) > 65535`, the u16
|
||||
/// length field would wrap) and the address-encoding refusals.
|
||||
pub async fn send_datagram(
|
||||
&mut self,
|
||||
dst: impl ToTargetAddr,
|
||||
payload: &[u8],
|
||||
) -> Result<(), SocksSessionError> {
|
||||
let dst: TargetAddr = dst.to_target_addr()?;
|
||||
let framed = frame(&dst, payload).map_err(wire_error)?;
|
||||
let Some(inner) = self.inner.as_mut() else {
|
||||
return Err(SocksSessionError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"the association's stream is spent",
|
||||
)));
|
||||
};
|
||||
let socket = inner.get_socket_mut();
|
||||
socket.write_all(&framed).await?;
|
||||
socket.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive the next datagram: decode incrementally from the
|
||||
/// channel's read half — `(source_addr, payload)` per datagram,
|
||||
/// `Ok(None)` on channel EOF (the association's end — the
|
||||
/// association lifetime is the channel's, ADR-003).
|
||||
///
|
||||
/// **The reader state persists across calls** (POC #2's finding,
|
||||
/// ADR-003): the incremental [`DatagramReader`]'s mid-frame
|
||||
/// position survives a caller-driven receive loop — a datagram
|
||||
/// split across channel chunks reassembles across calls — and
|
||||
/// frames decoded beyond the one returned (a chunk may batch
|
||||
/// several datagrams) queue in `pending` for later calls.
|
||||
///
|
||||
/// Fail-loud shapes (never a silent partial datagram): a stream
|
||||
/// end mid-frame is the truncated-datagram condition (an
|
||||
/// `UnexpectedEof` I/O error through [`SocksSessionError::Rfc`]);
|
||||
/// a frame the codec refuses (garbage ATYP — any post-reply
|
||||
/// control byte) errors loud through [`WireError`]'s
|
||||
/// `UdpHeaderError` mapping and ends the caller's loop.
|
||||
pub async fn recv_datagram(
|
||||
&mut self,
|
||||
) -> Result<Option<(TargetAddr, Bytes)>, SocksSessionError> {
|
||||
// The batched-datagram pending queue first (a chunk may have
|
||||
// batched several frames — drain in order).
|
||||
if let Some(dg) = self.pending.pop_front() {
|
||||
return Ok(Some(dg));
|
||||
}
|
||||
|
||||
let mut chunk = vec![0u8; 16 * 1024];
|
||||
let Some(inner) = self.inner.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let socket = inner.get_socket_mut();
|
||||
loop {
|
||||
let n = socket.read(&mut chunk).await?;
|
||||
if n == 0 {
|
||||
// Channel EOF = association end (the clean exit) —
|
||||
// fail-loud if a frame was in flight (never a silent
|
||||
// partial datagram; the reader state is the teardown
|
||||
// diagnostic).
|
||||
if self.reader.is_mid_frame() {
|
||||
return Err(SocksSessionError::Rfc(SocksError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"association ended mid-datagram (fail-loud)",
|
||||
))));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let mut dgs = self.reader.feed(&chunk[..n]).into_iter();
|
||||
if let Some(first) = dgs.next() {
|
||||
for res in dgs {
|
||||
match res {
|
||||
Ok(pair) => self.pending.push_back(pair),
|
||||
Err(err) => return Err(wire_error(err)),
|
||||
}
|
||||
}
|
||||
match first {
|
||||
Ok(pair) => return Ok(Some(pair)),
|
||||
Err(err) => return Err(wire_error(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying stream as split read/write halves (the front
|
||||
/// door's bridge seam: a raw pipe to a vanilla client over the
|
||||
/// framed stage — the door's RFC header codec owns the frames at
|
||||
/// the boundary, ADR-003). `None` after the stream was taken (the
|
||||
/// spent-session posture).
|
||||
pub fn split(&mut self) -> Option<SplitPipe<'_>> {
|
||||
let inner = self.inner.as_mut()?;
|
||||
Some(tokio::io::split(inner))
|
||||
}
|
||||
|
||||
/// Take the underlying [`Socks5Stream<BiStream>`] (advanced use —
|
||||
/// the forked client type's socket accessors, owned-half pumps;
|
||||
/// the POC's `raw_mut` seam, widened). The session rides along
|
||||
/// still owning the channel's teardown: drop the pair (or
|
||||
/// `shutdown`/`close` the session) when done.
|
||||
pub fn into_inner(mut self) -> TakenAssociateInner {
|
||||
let inner = self.inner.take();
|
||||
TakenAssociateInner {
|
||||
session: self,
|
||||
inner: inner.expect("into_inner on a spent session"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-close the stream (the association's graceful end): the
|
||||
/// mux's implicit-EOF sentinel reaches the producer's stage as
|
||||
/// EOF and the association ends (association lifetime = channel
|
||||
/// lifetime, ADR-003). Returns whether the adopted channel entry
|
||||
/// existed (the reap result; the session is spent either way).
|
||||
pub async fn shutdown(mut self) -> Result<bool, SocksSessionError> {
|
||||
let reaped = if let Some(inner) = self.inner.as_mut() {
|
||||
let socket = inner.get_socket_mut();
|
||||
socket.shutdown().await?;
|
||||
self.inner.take();
|
||||
self.reap()
|
||||
} else {
|
||||
self.reap()
|
||||
};
|
||||
Ok(reaped)
|
||||
}
|
||||
|
||||
/// Tear the association down gracefully (the CONNECT session's
|
||||
/// `close` posture): half-close the stream (the EOF sentinel
|
||||
/// reaches the producer's stage), 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 AssociateSession {
|
||||
/// 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 stage —
|
||||
/// the association ends with the session's lifetime). Dropping
|
||||
/// without `shutdown`/`close` never leaks the channel entry.
|
||||
fn drop(&mut self) {
|
||||
self.inner.take();
|
||||
let _ = self.reap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The owned stream [`AssociateSession::into_inner`] yields, paired
|
||||
/// with the session that still owns the channel's teardown.
|
||||
pub struct TakenAssociateInner {
|
||||
/// The teardown owner — reap via `shutdown`/`close`/`Drop`.
|
||||
pub session: AssociateSession,
|
||||
/// The framed datagram stage's stream (the forked client type
|
||||
/// over the channel's `BiStream`).
|
||||
pub inner: Socks5Stream<BiStream>,
|
||||
}
|
||||
|
||||
/// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired
|
||||
/// with the session that still owns the channel's teardown.
|
||||
pub struct TakenInner {
|
||||
@@ -194,7 +510,6 @@ pub type SplitPipe<'s> = (
|
||||
ReadHalf<&'s mut Socks5Stream<BiStream>>,
|
||||
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
|
||||
@@ -464,6 +779,28 @@ impl Drop for BindSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a datagram-frame decode error ([`WireError`]) into the RFC
|
||||
/// vocabulary the session surface carries: the address-encoding
|
||||
/// refusal is the forked [`AddrError`] class (the same sub-layout
|
||||
/// the forked codec validates — one vocabulary), the protocol-garbage
|
||||
/// shapes (bad ATYP / a short frame / non-UTF-8 domain / a `len == 0`
|
||||
/// frame) surface as the RFC UDP-header error class
|
||||
/// ([`UdpHeaderError`]) — the post-reply-garbage semantic, the
|
||||
/// consumer-side mirror of the producer stage's
|
||||
/// `UndecodableFrame` exit. The oversize family is unreachable on
|
||||
/// the receive path (the length codec's frame-time bound makes a
|
||||
/// declared length undecodable only via the garbage shapes) — it
|
||||
/// maps through the UDP-header class too (fail-loud either way).
|
||||
fn wire_error(err: WireError) -> SocksSessionError {
|
||||
match err {
|
||||
WireError::Address(addr) => SocksError::AddrError(addr).into(),
|
||||
other => SocksError::UdpHeaderError(UdpHeaderError::ReadingError(std::io::Error::other(
|
||||
other.to_string(),
|
||||
)))
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -494,6 +831,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::channels::client::ChannelOpenError;
|
||||
use futures::future::FutureExt as _;
|
||||
|
||||
fn assert_send<T: Send>() {}
|
||||
|
||||
@@ -534,4 +872,176 @@ mod tests {
|
||||
let session: SocksSessionError = open.into();
|
||||
assert!(matches!(session, SocksSessionError::Open(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn associate_session_shape_is_send_and_not_clone() {
|
||||
assert_send::<AssociateSession>();
|
||||
assert_send::<TakenAssociateInner>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn associate_debug_impl_names_the_channel_and_liveness() {
|
||||
let session = AssociateSession {
|
||||
channel_id: 7,
|
||||
manager: None,
|
||||
inner: None,
|
||||
reader: DatagramReader::new(),
|
||||
pending: Default::default(),
|
||||
relay_addr: None,
|
||||
};
|
||||
let rendered = format!("{session:?}");
|
||||
assert!(rendered.contains("channel_id: 7"), "{rendered}");
|
||||
assert!(rendered.contains("associated: false"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spent_associate_reap_is_a_no_op() {
|
||||
let mut session = AssociateSession {
|
||||
channel_id: 9,
|
||||
manager: None,
|
||||
inner: None,
|
||||
reader: DatagramReader::new(),
|
||||
pending: Default::default(),
|
||||
relay_addr: None,
|
||||
};
|
||||
assert!(!session.reap(), "no manager — nothing reaped");
|
||||
assert!(session.split().is_none(), "no stream after teardown");
|
||||
assert!(session.relay_addr().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recv_datagram_persists_reader_state_across_calls() {
|
||||
use crate::wire::frame as wire_frame;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
// The frame-source side: two frames, the first one split
|
||||
// across two writes (the 2-byte-prefix chunk shape — the POC
|
||||
// finding's trigger) and the second batched into the same
|
||||
// read batch. The session's reader state must survive the
|
||||
// call boundary: the first call returns the first datagram
|
||||
// (reassembled across reads), the second drains the pending
|
||||
// queue.
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
|
||||
let wire = {
|
||||
let mut v = wire_frame(&addr, b"0123456789").unwrap().to_vec();
|
||||
v.extend_from_slice(&wire_frame(&addr, b"second").unwrap());
|
||||
v
|
||||
};
|
||||
writer.write_all(&wire[..5]).await.unwrap();
|
||||
writer.write_all(&wire[5..]).await.unwrap();
|
||||
drop(writer);
|
||||
|
||||
let mut session = build_test_session(reader);
|
||||
|
||||
let (src, first) = session
|
||||
.recv_datagram()
|
||||
.await
|
||||
.expect("recv")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, addr);
|
||||
assert_eq!(&first[..], b"0123456789");
|
||||
|
||||
// The second frame was batched in the same read batch: the
|
||||
// pending queue carries it to the next call.
|
||||
let (src, second) = session
|
||||
.recv_datagram()
|
||||
.await
|
||||
.expect("recv")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, addr);
|
||||
assert_eq!(&second[..], b"second");
|
||||
|
||||
// EOF: both frames consumed, the queue empty — `None`.
|
||||
assert!(session.recv_datagram().await.expect("eof").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recv_datagram_fails_loud_when_stream_ends_mid_frame() {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
let addr = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
|
||||
let framed = crate::wire::frame(&addr, b"payload").unwrap();
|
||||
writer.write_all(&framed[..4]).await.unwrap();
|
||||
drop(writer);
|
||||
|
||||
let mut session = build_test_session(reader);
|
||||
let err = session
|
||||
.recv_datagram()
|
||||
.await
|
||||
.expect_err("a stream end mid-frame is the truncated-datagram condition");
|
||||
assert!(
|
||||
matches!(err, SocksSessionError::Rfc(SocksError::Io(_))),
|
||||
"fail-loud, not a silent partial: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recv_datagram_fails_loud_on_an_undecodable_frame() {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
// Hand-craft a frame whose inner bytes start with an
|
||||
// unassigned ATYP (the post-reply-garbage semantic).
|
||||
let inner_bytes = vec![0x7Fu8, 1, 2, 3];
|
||||
let mut garbage = (inner_bytes.len() as u16).to_be_bytes().to_vec();
|
||||
garbage.extend_from_slice(&inner_bytes);
|
||||
writer.write_all(&garbage).await.unwrap();
|
||||
drop(writer);
|
||||
|
||||
let mut session = build_test_session(reader);
|
||||
let err = session
|
||||
.recv_datagram()
|
||||
.await
|
||||
.expect_err("garbage ATYP is loud, never a silent drop");
|
||||
assert!(matches!(err, SocksSessionError::Rfc(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_error_maps_the_decode_garbage_shapes() {
|
||||
// The address-encoding refusal rides the forked AddrError
|
||||
// class; the garbage shapes ride the UDP-header class.
|
||||
let addr_err =
|
||||
crate::wire::frame(&TargetAddr::Domain("a".repeat(256), 80), b"").unwrap_err();
|
||||
let mapped = wire_error(addr_err);
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
SocksSessionError::Rfc(SocksError::AddrError(_))
|
||||
));
|
||||
|
||||
let garbage = crate::wire::DatagramReader::new()
|
||||
.feed(&[0x00, 0x04, 0x7f, 1, 2, 3])
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
let mapped = wire_error(garbage);
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
SocksSessionError::Rfc(SocksError::UdpHeaderError(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// A session over a raw duplex (the reader internals' test
|
||||
/// harness — the handshake-free path: `skip_auth` bypasses the
|
||||
/// greeting, no reply is expected, the datagram stage rides
|
||||
/// raw).
|
||||
fn build_test_session(session_end: tokio::io::DuplexStream) -> AssociateSession {
|
||||
let mut config = Config::default();
|
||||
config.set_skip_auth(true);
|
||||
let (read, write) = tokio::io::split(session_end);
|
||||
let inner = Socks5Stream::use_stream(BiStream::from_joined(read, write), None, config)
|
||||
.now_or_never()
|
||||
.expect("skip_auth's use_stream is instantly ready")
|
||||
.expect("skip_auth's use_stream succeeds");
|
||||
AssociateSession {
|
||||
channel_id: 1,
|
||||
manager: None,
|
||||
inner: Some(inner),
|
||||
reader: DatagramReader::new(),
|
||||
pending: Default::default(),
|
||||
relay_addr: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -29,7 +29,10 @@ pub mod params;
|
||||
pub mod producer;
|
||||
pub mod wire;
|
||||
|
||||
pub use consumer::{BindDataPlane, BindReply1, BindSession, Socks5ChannelSession};
|
||||
pub use consumer::{
|
||||
AssociateSession, BindDataPlane, BindReply1, BindSession, Socks5ChannelSession,
|
||||
TakenAssociateInner,
|
||||
};
|
||||
pub use egress::{
|
||||
egress_factory, BindAccept, BindAcceptError, BindAcceptFactory, BindAcceptFactoryError,
|
||||
BindTargetHandle, DialFn, Egress, EgressError, EgressFactory, SocksDialError, TargetHandle,
|
||||
|
||||
@@ -200,6 +200,12 @@ impl ToTargetAddr for TargetAddr {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTargetAddr for &TargetAddr {
|
||||
fn to_target_addr(&self) -> io::Result<TargetAddr> {
|
||||
Ok((*self).clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Addr {
|
||||
V4([u8; 4]),
|
||||
|
||||
Reference in new issue
Block a user