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:
1 parent
df772ccbeb
commit
76717f3944
5 files changed
+876
-7
No files matched your search
+237
-4
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user