consumer: Socks5ChannelSession (CONNECT) — teardown ownership + e2e suite

- Socks5ChannelSession::connect: open_channel(OP_SOCKS5_OPEN, {},
  SOCKS5_ALPN) then the forked client typestate's use_stream path
  over the channel BiStream (POC #1's fit); post-reply the session
  is the raw byte pipe (0 B framing)
- Target construction via ToTargetAddr: IP-string forms parse to IP
  targets, domain form passes through unresolved (ADR-012)
- split() borrowed halves (SplitPipe) / into_inner() owned pipe
  (TakenInner — the session rides along as the teardown owner);
  channel_id() for correlation
- Teardown ownership (alktunnels ADR-005 posture): Drop sync-reaps
  the adopted entry (teardown_channel is sync; Drop cannot await) +
  the pipe drop EOFs the producer pump structurally; close() is the
  graceful path (write-half shutdown, then reap); the in-stream
  refusal tears the channel down in connect (teardown-at-birth —
  no phantom channel); no Clone (compile_fail doctest)
- Error surface: open failures are the typed SocksOpenError reasons
  (ADR-049 §4); a refused target is the RFC reply code through
  SocksSessionError::Rfc (consumer.md §Error surface)
- Harness: the consumer-side topology (wire_consumer_side[_with] —
  the consumer dials with a real ChannelClient; the producer accepts
  with the real registration) + witnessing_echo_dial; the
  allow(dead_code) attr rides the alktunnels harness posture
- tests/consumer-connect.rs: 9 e2e tests — echo round-trip + CF-006
  witness, target forms at the dial seam (ADR-012), refused dial →
  typed RFC error + no phantom channel, FORBIDDEN open → typed open
  error + no session, drop teardown + connection health, half-close
  EOF semantics, into_inner pairing, Send shape

Verified: cargo test (219 green incl. the compile_fail doctest),
clippy --all-targets -D warnings, fmt --check,
check/clippy --target wasm32-unknown-unknown
This commit is contained in:
glm-5.3-flash committed 2026-09-23 23:50:01 +00:00
1 parent df772ccbeb
commit 76717f3944
5 files changed
+876 -7

No files matched your search

+237 -4
View File
@@ -1,5 +1,238 @@
//! The consumer half: the channels-native session types (CONNECT /
//! ASSOCIATE / the BIND reply#2 reader) and teardown ownership.
//! The consumer half: the channels-native session types and teardown
//! ownership (consumer.md §The Channels-Native Sessions).
//!
//! Stub: filled by the `implementation/core-consumer-*` tasks
//! (CONNECT first).
//! [`Socks5ChannelSession`] is the CONNECT session: it opens
//! `channels/socks5/sub` via `ChannelClient::open_channel` (trivial
//! params — ADR-001), drives the forked client typestate's
//! `use_stream` path over the channel's `BiStream` (POC #1's fit —
//! `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`).
//!
//! 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).
//!
//! 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
//! 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.
//!
//! 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`].
use alkcall::channels::client::ChannelClient;
use alkcall::channels::manager::ChannelManager;
use alkcall::core::types::BiStream;
use tokio::io::{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;
/// A completed SOCKS5 CONNECT session on the consumer side: the
/// adopted channel + the post-reply byte pipe to the target.
///
/// The session is a handle to one channel (one session = one channel
/// = one CONNECT, convention 10). It does **not** implement `Clone`:
///
/// ```compile_fail
/// use alksocks::consumer::Socks5ChannelSession;
/// fn assert_clone<T: Clone>() {}
/// assert_clone::<Socks5ChannelSession>();
/// ```
///
/// The inner stream is `Option` so `Drop`/`close` can empty the
/// type (the alktunnels `Drop`-impl posture); a spent session drops
/// as a teardown-only no-op.
pub struct Socks5ChannelSession {
channel_id: u32,
manager: Option<ChannelManager>,
inner: Option<Socks5Stream<BiStream>>,
}
impl std::fmt::Debug for Socks5ChannelSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Socks5ChannelSession")
.field("channel_id", &self.channel_id)
.field("connected", &self.inner.is_some())
.finish()
}
}
impl Socks5ChannelSession {
/// CONNECT to `target` 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 CONNECT request in-stream over the channel's
/// `BiStream`, and returns the session owning both.
///
/// Error surface: an open failure is [`SocksSessionError::Open`]
/// (the typed ADR-049 reasons; no channel was ever returned); a
/// refused target 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 connect(
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?;
inner.request(Socks5Command::TCPConnect, target).await?;
Result::<Socks5Stream<BiStream>, SocksSessionError>::Ok(inner)
};
match negotiated.await {
Ok(inner) => Ok(Self {
channel_id,
manager: Some(client.manager().clone()),
inner: Some(inner),
}),
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 post-CONNECT pipe as split read/write halves (0 B framing
/// — raw bytes to/from the target). The halves borrow the
/// session: teardown stays owned (`close`/`Drop`). `None` after
/// [`Self::into_inner`] took the pipe.
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 session rides along still owning the channel's teardown:
/// drop the pair (or `close` the session) when done — dropping
/// `inner` EOFs the data plane structurally.
pub fn into_inner(mut self) -> TakenInner {
let inner = self.inner.take();
TakenInner {
session: self,
inner: inner.expect("into_inner on a spent session"),
}
}
/// 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 Socks5ChannelSession {
/// 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();
}
}
/// The owned pipe [`Socks5ChannelSession::into_inner`] yields, paired
/// with the session that still owns the channel's teardown.
pub struct TakenInner {
/// The teardown owner — reap via `close`/`Drop`.
pub session: Socks5ChannelSession,
/// The post-CONNECT pipe (the forked client type over the
/// channel's `BiStream`).
pub inner: Socks5Stream<BiStream>,
}
/// The borrowed halves [`Socks5ChannelSession::split`] yields (the
/// `tokio::io::split` pair over the pipe). A half-close rides the
/// write half's [`AsyncWriteExt::shutdown`].
pub type SplitPipe<'s> = (
ReadHalf<&'s mut Socks5Stream<BiStream>>,
WriteHalf<&'s mut Socks5Stream<BiStream>>,
);
#[cfg(test)]
mod tests {
use super::*;
use alkcall::channels::client::ChannelOpenError;
fn assert_send<T: Send>() {}
#[test]
fn session_shape_is_send_and_not_clone() {
assert_send::<Socks5ChannelSession>();
assert_send::<TakenInner>();
}
#[test]
fn debug_impl_names_the_channel_and_liveness() {
let session = Socks5ChannelSession {
channel_id: 7,
manager: None,
inner: None,
};
let rendered = format!("{session:?}");
assert!(rendered.contains("channel_id: 7"), "{rendered}");
assert!(rendered.contains("connected: false"), "{rendered}");
}
#[test]
fn spent_session_reap_is_a_no_op() {
let mut session = Socks5ChannelSession {
channel_id: 9,
manager: None,
inner: None,
};
assert!(!session.reap(), "no manager — nothing reaped");
assert!(session.split().is_none(), "no pipe after teardown");
}
#[test]
fn open_errors_carry_the_typed_surface_through_connects_mapping() {
// The mapping `connect` applies to an open failure: the typed
// `SocksOpenError` vocabulary rides `SocksSessionError::Open`.
let open = SocksOpenError::Open(ChannelOpenError::MissingChannelId);
let session: SocksSessionError = open.into();
assert!(matches!(session, SocksSessionError::Open(_)));
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod params;
pub mod producer;
pub mod wire;
pub use consumer::Socks5ChannelSession;
pub use egress::{
egress_factory, BindAccept, BindAcceptError, BindAcceptFactory, BindAcceptFactoryError,
BindTargetHandle, DialFn, Egress, EgressError, EgressFactory, SocksDialError, TargetHandle,