Files
alksocks/src/door.rs
T
glm-5.3-flash b8fd75b9f3 door: the TCP front door — NoAuth raw pipe + RFC 1929 cred-bearing + BIND
The consumer's TCP front door (feature `local`, ADR-005/ADR-014): the
ssh `-D` local exposure vanilla clients point at.

- `src/door.rs`: `FrontDoor::builder().credentials(...).bind(...)`
  → `serve`/`serve_on`. The NoAuth door is the literal raw pipe
  (accept → channel BiStream via copy_bidirectional, zero protocol
  translation). The cred-bearing door terminates RFC 1929 locally
  (the forked accept_password_auth shape) and re-originates in-band
  via the token-carrying consumer sessions — the vanilla replies are
  re-encoded at the door (the RFC codec boundary, ADR-003), the
  password never crosses the channel as a SOCKS password. The BIND
  door rides BindSession with reply#1 fidelity (the advertised
  address passes through unchanged). UDP ASSOCIATE at the TCP door
  refuses RFC-faithfully (0x07 — the UDP door's task). The ADR-014
  accept-path defense: the idle-first-byte window (peek-based,
  door-side close) + the max-sessions cap (semaphore) as constructor
  config; per-accept failure isolation (the loop continues).
- The identity mechanism (decided + recorded in the task Notes): the
  per-credential identity rides the open-op `auth_token` payload
  (CF-005 precedence (1)) — the token is the mapped identity's id;
  the producer resolves it through ServingConfig.identity_provider.
  No per-credential ChannelClient.
- `src/consumer.rs`: `TokenClient` (the token-carrying open surface
  over the taken CallConnection + shared manager) and the
  `*_with_token` twins of the three session constructors through the
  open_with_token_and_negotiate prologue (teardown-at-birth
  preserved); `Socks5ChannelSession::reply_addr` (the CONNECT
  reply's BND.ADDR for the door's re-encoding). Existing surfaces
  untouched.
- `tests/door-tcp.rs`: 10 integration tests — the NoAuth raw-pipe
  round-trip (the POC #1 curl shape), good/bad credentials (the
  establisher witness sees the mapped identity, never the password;
  a rejection opens no channel), the never-pipes-raw-post-auth pin,
  the BIND door's vanilla conversation, the idle timeout, the
  session cap, failure isolation, and the native-session/BIND-token
  regression guards. Harness: wire_consumer_side_with_provider (the
  producer's identity provider configurable).

Verification: cargo test (375) + --all-features (423) green; clippy
-D warnings clean (default + all-features); fmt --check clean; wasm
check + wasm clippy green; default graph stays net-free.
2026-09-25 12:37:23 +00:00

653 lines
27 KiB
Rust

//! The consumer's TCP front door (feature `local`, ADR-005/ADR-014;
//! consumer.md §The Front Doors): the ssh `-D` local exposure — a real
//! `socks5://127.0.0.1:port` endpoint vanilla clients (curl, browsers,
//! tun2proxy) point at. The door is the vanilla↔ACL boundary; the RFC
//! codec lives only here (ADR-003's "codec at front doors only").
//!
//! Two door variants (ADR-005), selected by constructor config:
//!
//! - **The NoAuth door** (POC #1's raw-pipe shape): per accepted
//! connection, a literal raw pipe onto a channel `BiStream`
//! (`copy_bidirectional`, **zero protocol translation**). The
//! producer runs the whole RFC conversation through the pipe — the
//! door adds nothing. The loopback-only / wrapper-aware shape.
//! - **The cred-bearing door** (RFC 1929): the door terminates the
//! local RFC conversation itself — the forked
//! `accept_password_auth` shape (`start` →
//! `negotiate_auth(&[PasswordAuthentication])` →
//! `read_username_password` → check → `accept`/`reject`) — then
//! maps the accepted credential through the **static config table**
//! (`user:pass →` channel identity, ADR-005) and **re-originates
//! the SOCKS conversation in-band** via the token-carrying consumer
//! sessions (`Socks5ChannelSession::connect_with_token` /
//! `BindSession::bind_with_token`), re-encoding the in-band replies
//! to the vanilla client and piping the post-auth data-plane bytes
//! raw. **The door never pipes raw post-auth**: the vanilla
//! client's command bytes were consumed by the door's own RFC 1929
//! half; the in-band path re-speaks them from the parsed target.
//! The SOCKS password never crosses the channel as a SOCKS
//! password; the channel identity never reaches the vanilla client.
//!
//! **The identity mechanism** (the CF-005 seam; decided at
//! implementation, recorded in the task Notes): the credential→identity
//! mapping yields an alkcall [`Identity`] (scopes included); the
//! per-credential identity rides the open-op call's `auth_token`
//! payload (CF-005 precedence (1): payload `auth_token` →
//! `identity_provider` resolution — alkcall `dispatch::
//! resolve_identity`, ADR-017 §7), never a per-credential
//! `ChannelClient`. The token is the mapped identity's id — the
//! resolution seam's vocabulary; the password itself never rides it.
//! The producer resolves it through its `ServingConfig
//! .identity_provider` and the ACL gate reads the resolved scopes
//! exactly as for any per-call opener identity (CF-005/CF-006). The
//! door checks credentials itself — no forked `AuthMethod` impl
//! (ADR-005).
//!
//! **The accept path owns the idle defense** (ADR-014): an accepted
//! connection that sends no first byte within the configured window
//! is closed door-side (an accepted socket is not a channel — the cap
//! regime does not bound it); plus the max-concurrent-sessions cap
//! (an accept beyond the bound is refused door-side, no channel
//! opened). The two v1 knobs are constructor config.
//!
//! **The BIND door**: the same listener shape; a vanilla BIND
//! conversation rides [`BindSession`] (reply#1's advertised address
//! passes through to the vanilla client unchanged — the conversation
//! is the client's own; the door adds nothing, consumer.md §The BIND
//! door's reply#2 fidelity). The BIND door rides the same door
//! variant split (NoAuth raw-pipe vs cred-bearing re-origination).
//!
//! Wiring (consumer.md §Wiring): `FrontDoor::builder()
//! .credentials(cred_table).bind("127.0.0.1:1080").build()` →
//! `serve(channel_client)` — the NoAuth door omits `.credentials`.
//! `serve` runs the accept loop; per-connection failures are logged +
//! isolated (the loop continues — the alktunnels per-accept isolation
//! posture).
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use alkcall::channels::client::ChannelClient;
use alkcall::core::auth::Identity;
use tokio::io::AsyncWriteExt as _;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Semaphore;
use crate::consumer::{TakenInner, TokenClient};
use crate::rfc1928::consts;
use crate::rfc1928::server::{new_reply, states, Socks5ServerProtocol, SocksServerError};
use crate::rfc1928::util::target_addr::TargetAddr;
use crate::rfc1928::{ReplyError, Socks5Command, SocksError};
/// One static credential-table entry (ADR-005): `user:pass →` channel
/// identity. The table is the door's credential→identity mapping —
/// static, declarative, assembly-layer.
#[derive(Clone, Debug)]
pub struct DoorCredential {
/// The RFC 1929 username the vanilla client presents.
pub username: String,
/// The RFC 1929 password the vanilla client presents.
pub password: String,
/// The channel identity the accepted credential maps to (scopes
/// included — the open gate and the BIND command gate read them
/// producer-side). The identity's `id` doubles as the open-op
/// `auth_token` (the resolution seam's vocabulary — the password
/// never rides it).
pub identity: Identity,
}
/// The credential→identity static config table (ADR-005): exact
/// `username`/`password` lookup. A miss rejects the RFC 1929
/// conversation door-side (no channel opened — the ACL boundary held;
/// the vanilla client got the RFC 1929 rejection).
#[derive(Clone, Debug, Default)]
pub struct CredentialTable {
entries: HashMap<(String, String), Identity>,
}
impl CredentialTable {
/// Build the table from [`DoorCredential`] entries.
pub fn new(entries: impl IntoIterator<Item = DoorCredential>) -> Self {
Self {
entries: entries
.into_iter()
.map(|c| ((c.username, c.password), c.identity))
.collect(),
}
}
/// The exact-match lookup: `(username, password) → Option<identity>`.
pub fn lookup(&self, username: &str, password: &str) -> Option<&Identity> {
self.entries
.get(&(username.to_string(), password.to_string()))
}
/// The number of entries (observability).
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the table is empty (an empty cred-bearing table
/// rejects every conversation door-side).
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// Why one accepted connection's serving task ended (the door's
/// per-connection teardown diagnostic — logged + isolated).
#[derive(Debug)]
enum DoorExit {
/// The vanilla client went away (clean end, both variants).
ClientGone,
/// The data plane (local pipe ↔ in-band session) ended.
PlaneEnded,
/// The local RFC half failed (a rejected credential, a malformed
/// conversation, protocol garbage) — the vanilla client's fault.
LocalRfc(SocksError),
/// The in-band open failed (the producer refused the mapped
/// identity, the target dial refused, the channel open errored).
Open(String),
/// A plain I/O failure on the local socket.
Io(std::io::Error),
}
impl std::fmt::Display for DoorExit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DoorExit::ClientGone => f.write_str("client gone"),
DoorExit::PlaneEnded => f.write_str("data plane ended"),
DoorExit::LocalRfc(err) => write!(f, "local rfc: {err}"),
DoorExit::Open(msg) => write!(f, "open failed: {msg}"),
DoorExit::Io(err) => write!(f, "io: {err}"),
}
}
}
impl From<SocksServerError> for DoorExit {
fn from(err: SocksServerError) -> Self {
DoorExit::LocalRfc(SocksError::ServerError(err))
}
}
/// The TCP front door (feature `local`): the vanilla↔ACL boundary.
/// Variant selection is constructor config (ADR-005): credentials
/// configured = the cred-bearing door (RFC 1929 terminates locally,
/// re-origination in-band); credentials absent = the NoAuth door (the
/// literal raw pipe). The accept path's defense knobs (ADR-014) are
/// constructor config: the idle-first-byte window and the
/// max-concurrent-sessions cap.
pub struct FrontDoor {
bind_addr: SocketAddr,
credentials: Option<CredentialTable>,
idle_first_byte: Duration,
max_sessions: usize,
}
impl FrontDoor {
/// The builder (consumer.md §Wiring).
pub fn builder() -> FrontDoorBuilder {
FrontDoorBuilder::default()
}
}
/// The builder for [`FrontDoor`] (consumer.md §Wiring: `builder()
/// .credentials(cred_table).bind("127.0.0.1:1080").build()`). The
/// NoAuth door omits `.credentials`.
#[derive(Default)]
pub struct FrontDoorBuilder {
bind_addr: Option<SocketAddr>,
credentials: Option<CredentialTable>,
idle_first_byte: Option<Duration>,
max_sessions: Option<usize>,
}
impl FrontDoorBuilder {
/// The credential→identity static config table (ADR-005).
/// Configuring it selects the cred-bearing door (RFC 1929);
/// omitting it selects the NoAuth door (the raw-pipe shape).
pub fn credentials(mut self, table: CredentialTable) -> Self {
self.credentials = Some(table);
self
}
/// The local bind address — the door's explicit bind decision
/// (the never-binds posture: the door is the assembly layer's
/// spelled-out choice; loopback is the documented posture).
pub fn bind(mut self, addr: impl Into<SocketAddr>) -> Self {
self.bind_addr = Some(addr.into());
self
}
/// The idle-first-byte window (ADR-014's accept-path defense
/// knob 1): an accepted connection that sends no first byte
/// within this window is closed door-side. The window covers the
/// first byte of the local conversation — the NoAuth door's
/// greeting bytes (read producer-side through the pipe) and the
/// cred-bearing door's greeting (read by the door's own RFC 1929
/// half).
pub fn idle_first_byte(mut self, window: Duration) -> Self {
self.idle_first_byte = Some(window);
self
}
/// The max-concurrent-sessions cap (ADR-014's accept-path defense
/// knob 2): an accept beyond the bound is refused door-side — the
/// socket is closed immediately, no channel opened.
pub fn max_sessions(mut self, cap: usize) -> Self {
self.max_sessions = Some(cap);
self
}
pub fn build(self) -> FrontDoor {
FrontDoor {
bind_addr: self.bind_addr.expect("the door binds an address"),
credentials: self.credentials,
idle_first_byte: self.idle_first_byte.unwrap_or(Duration::from_secs(30)),
max_sessions: self.max_sessions.unwrap_or(256),
}
}
}
impl FrontDoor {
/// Bind the configured address and run the accept loop
/// (consumer.md §Wiring: `door.serve(channel_client)`). Per-
/// connection failures are logged + isolated — the loop continues
/// (the alktunnels per-accept isolation posture). Runs until the
/// listener fails fatally; the caller owns the task's lifetime.
pub async fn serve(self, client: Arc<ChannelClient>) -> std::io::Result<()> {
let listener = TcpListener::bind(self.bind_addr).await?;
tracing::info!(bind = %self.bind_addr, "front door: listening");
self.serve_on(listener, client).await
}
/// Run the accept loop over an existing listener (the
/// test/assembly seam — the same accept loop without the bind).
pub async fn serve_on(
self,
listener: TcpListener,
client: Arc<ChannelClient>,
) -> std::io::Result<()> {
let credentials = self.credentials;
let idle = self.idle_first_byte;
let live = Arc::new(Semaphore::new(self.max_sessions));
// The cred-bearing door's channel surface: the client's call
// connection is taken once for the door's lifetime (one
// transport, one call connection, many per-credential
// identities riding the open-op `auth_token` payloads). The
// NoAuth door keeps the client's own calling surface
// (`open_channel` — the raw-pipe shape never carries a
// token).
let token_client = if credentials.is_some() {
Some(Arc::new(TokenClient::wrap(&client).await))
} else {
None
};
loop {
let (stream, peer) = match listener.accept().await {
Ok(accepted) => accepted,
Err(err) => {
tracing::warn!(error = %err, "front door: accept failed");
continue;
}
};
// The session cap (ADR-014 knob 2): refuse beyond the
// bound — `try_acquire` keeps the accept loop free (a
// permit returns when a serving task ends and drops its
// guard).
let Ok(permit) = live.clone().try_acquire_owned() else {
tracing::warn!(peer = %peer, "front door: session cap reached — refusing");
drop(stream);
continue;
};
let client = Arc::clone(&client);
let per_accept_credentials = credentials.clone();
let per_accept_tokens = token_client.clone();
tokio::spawn(async move {
let _permit = permit;
let exit = serve_connection(
stream,
peer,
&client,
per_accept_credentials.as_ref(),
per_accept_tokens.as_ref(),
idle,
)
.await;
tracing::debug!(peer = %peer, "front door: connection ended: {exit}");
});
}
}
}
/// Serve one accepted connection through the door variant split: the
/// idle-first-byte gate first (ADR-014 knob 1 — the door's accept
/// path owns the defense; an accepted socket is not a channel), then
/// the variant.
async fn serve_connection(
stream: TcpStream,
peer: SocketAddr,
client: &Arc<ChannelClient>,
credentials: Option<&CredentialTable>,
token_client: Option<&Arc<TokenClient>>,
idle: Duration,
) -> DoorExit {
// The idle-first-byte defense: an accepted connection that sends
// no first byte within the window is closed door-side (ADR-014's
// slowloris sliver). The peek waits for readability without
// consuming — the variant's own protocol read picks the byte up.
let mut first = [0u8; 1];
match tokio::time::timeout(idle, stream.peek(&mut first)).await {
Ok(Ok(_)) => {}
Ok(Err(err)) => return DoorExit::Io(err),
Err(_) => {
tracing::debug!(peer = %peer, "front door: idle-first-byte timeout");
return DoorExit::ClientGone;
}
}
match (credentials, token_client) {
// The NoAuth door: the literal raw pipe (POC #1's shape —
// zero protocol translation).
(None, _) => raw_pipe(stream, client).await,
// The cred-bearing door: RFC 1929 terminates locally,
// re-origination rides the token sessions.
(Some(table), Some(tokens)) => cred_bearing(stream, client, table, tokens).await,
// Unreachable by construction (`serve_on` builds the pair) —
// the arm exists to keep the match exhaustive without a
// panic path (convention 2).
(Some(_), None) => {
DoorExit::Open("the cred-bearing door's token surface was not built".to_string())
}
}
}
/// The NoAuth door's raw pipe (POC #1's `front_door_raw_pipe_vanilla_
/// client` shape): open a channel, then `copy_bidirectional` the
/// accepted socket onto the channel `BiStream` — the producer runs
/// the whole RFC conversation through the pipe; the door adds
/// nothing.
async fn raw_pipe(mut stream: TcpStream, client: &Arc<ChannelClient>) -> DoorExit {
let (_channel_id, send, recv) = match client
.open_channel(
crate::params::OP_SOCKS5_OPEN,
crate::params::Socks5Params::as_json(),
crate::params::SOCKS5_ALPN,
)
.await
{
Ok(open) => open,
Err(err) => return DoorExit::Open(err.to_string()),
};
let mut bidi = alkcall::core::types::BiStream::from_joined(recv, send);
let _ = tokio::io::copy_bidirectional(&mut stream, &mut bidi).await;
DoorExit::PlaneEnded
}
/// The cred-bearing door (ADR-005): terminate the vanilla RFC 1929
/// conversation's auth locally (the forked surface), map the accepted
/// credential through the static table, re-originate the SOCKS
/// conversation in-band via the token-carrying consumer sessions,
/// re-encode the in-band replies to the vanilla client, and pipe the
/// post-auth data-plane bytes raw.
///
/// The door never pipes raw post-auth: the vanilla client's command
/// bytes were consumed by the door's own RFC half; the in-band path
/// re-speaks them from the parsed target. The password never crosses
/// the channel as a SOCKS password (the in-band handshake is NoAuth —
/// the mapped identity is the authorization); the channel identity
/// never reaches the vanilla client.
async fn cred_bearing(
mut stream: TcpStream,
_client: &Arc<ChannelClient>,
table: &CredentialTable,
token_client: &Arc<TokenClient>,
) -> DoorExit {
// The local RFC 1929 half (the forked surface) + the command read.
let outcome = local_rfc1929_half(&mut stream, table).await;
let (cmd, target, identity) = match outcome {
Ok(triple) => triple,
Err(exit) => return exit,
};
// The in-band re-origination: the per-credential identity rides
// the open-op call's `auth_token` payload (CF-005 precedence
// (1)). The token is the mapped identity's id — the resolution
// seam's vocabulary; the password itself never rides it.
let token = identity.id.as_bytes().to_vec();
match cmd {
Socks5Command::TCPConnect => {
let session = match crate::consumer::Socks5ChannelSession::connect_with_token(
token_client,
target,
token,
)
.await
{
Ok(session) => session,
Err(err) => return DoorExit::Open(err.to_string()),
};
pipe_connect_plane(stream, session).await
}
Socks5Command::TCPBind => {
let session =
match crate::consumer::BindSession::bind_with_token(token_client, target, token)
.await
{
Ok(session) => session,
Err(err) => return DoorExit::Open(err.to_string()),
};
pipe_bind_plane(stream, session).await
}
// UDP ASSOCIATE through the TCP door is the UDP door's shape
// (the datagram plane's RFC codec boundary — ADR-003), not
// this one's: refuse RFC-faithfully — the vanilla client gets
// the refused reply through the door's own codec (no phantom
// data plane, no in-band session opened).
Socks5Command::UDPAssociate => {
let reply = new_reply(
&ReplyError::CommandNotSupported,
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
);
let _ = stream.write_all(&reply).await;
let _ = stream.flush().await;
DoorExit::LocalRfc(SocksError::ReplyError(ReplyError::CommandNotSupported))
}
}
}
/// The door's local RFC 1929 half (the forked surface — ADR-005's
/// accept_password_auth shape): `negotiate_auth(&[
/// PasswordAuthentication])` → `read_username_password` → check
/// through the static table → `accept`/`reject`, then
/// `read_command` for the re-origination's parsed target. The check
/// rides the forked `CheckResult`'s `bool` impl; the mapped identity
/// is recorded by the same table lookup the check ran (one table,
/// one truth) and re-yielded to the caller.
async fn local_rfc1929_half(
stream: &mut TcpStream,
table: &CredentialTable,
) -> Result<(Socks5Command, TargetAddr, Identity), DoorExit> {
// The check closure is sync (`FnMut(String, String) -> bool` over
// the forked `CheckResult`'s `bool` impl); the mapped identity is
// recorded through a sync witness (a `std` mutex — the closure
// runs once, synchronously).
let found: Arc<std::sync::Mutex<Option<Identity>>> = Arc::new(std::sync::Mutex::new(None));
let found_for_check = Arc::clone(&found);
let (proto, _ok) = Socks5ServerProtocol::<_, states::Authenticated>::accept_password_auth(
stream,
|user, pass| {
let mapped = table.lookup(&user, &pass).cloned();
// A poisoned lock would mean a panic mid-check — the
// `unwrap_or_else(into_inner)` convention keeps the door
// alive (AGENTS.md convention 2).
let mut slot = found_for_check.lock().unwrap_or_else(|e| e.into_inner());
let accepted = mapped.is_some();
*slot = mapped;
// The check IS the table lookup: a miss rejects (the
// forked `accept_password_auth` writes the RFC 1929
// rejection when the checker returns `false`).
accepted
},
)
.await
.map_err(DoorExit::from)?;
let identity = found
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
.ok_or_else(|| DoorExit::from(SocksServerError::AuthenticationRejected))?;
let (_proto, cmd, target) = proto.read_command().await.map_err(DoorExit::from)?;
Ok((cmd, target, identity))
}
/// Pipe the re-originated CONNECT session: the vanilla reply (the
/// in-band reply's BND.ADDR re-encoded — the RFC codec at the door
/// boundary only, ADR-003), then the data plane ↔ the accepted socket.
/// The door already consumed the vanilla client's greeting + command
/// bytes; the post-auth bytes ride the session's data plane raw from
/// here. The pump shape is the taken inner stream
/// (`Socks5Stream<BiStream>` is itself `AsyncRead + AsyncWrite` — the
/// forked client type's intended use) against the local socket via
/// `copy_bidirectional`; the session stays the channel-teardown owner
/// (the `into_inner` pairing; dropped at the end — the EOF sentinel
/// reaches the producer's pump).
async fn pipe_connect_plane(
mut stream: TcpStream,
session: crate::consumer::Socks5ChannelSession,
) -> DoorExit {
// The vanilla CONNECT reply: the in-band reply's BND.ADDR
// re-encoded (the vanilla client is waiting for it — the door's
// RFC half consumed its local bytes; the in-band conversation is
// fresh and re-originated). The sentinel address is the
// RFC-compatibility surface the producer already chose; the door
// adds nothing.
let reply = match session.reply_addr() {
Some(TargetAddr::Ip(sa)) => new_reply(&ReplyError::Succeeded, sa),
Some(TargetAddr::Domain(_, port)) => new_reply(
&ReplyError::Succeeded,
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port),
),
None => {
session.close().await;
return DoorExit::PlaneEnded;
}
};
if stream.write_all(&reply).await.is_err() {
session.close().await;
return DoorExit::ClientGone;
}
let _ = stream.flush().await;
let Ok(TakenInner { session, inner }) = session.into_inner() else {
return DoorExit::PlaneEnded;
};
let _ = tokio::io::copy_bidirectional(&mut stream, &mut { inner }).await;
let _ = session;
DoorExit::PlaneEnded
}
/// Pipe the re-originated BIND session (the BIND door, cred-bearing
/// variant): reply#1's advertised address is re-encoded to the
/// vanilla client from the session's captured `bind_addr` (the
/// conversation is the vanilla client's own — the door's RFC half
/// consumed the local greeting + command bytes, and the in-band
/// session's reply#1 address passes through re-encoded; consumer.md
/// §The BIND door's reply#2 fidelity). The quiet wait rides
/// `await_inbound` (the take-guarded reply#2 reader); reply#2's peer
/// is likewise re-encoded; the data plane passes through raw. The
/// session stays the channel-teardown owner.
async fn pipe_bind_plane(
mut stream: TcpStream,
mut session: crate::consumer::BindSession,
) -> DoorExit {
// Reply#1 (the vanilla form): the in-band reply's BND.ADDR
// re-encoded — the advertised address passes through unchanged.
let bind_addr = session.reply1().map(|reply1| reply1.bind_addr.clone());
let Some(bind_addr) = bind_addr else {
session.close().await;
return DoorExit::PlaneEnded;
};
let wire = match &bind_addr {
TargetAddr::Ip(sa) => new_reply(&ReplyError::Succeeded, *sa),
TargetAddr::Domain(name, port) => {
let mut wire = vec![
consts::SOCKS5_VERSION,
ReplyError::Succeeded.as_u8(),
0x00,
consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME,
];
wire.push(name.len() as u8);
wire.extend_from_slice(name.as_bytes());
wire.extend_from_slice(&port.to_be_bytes());
wire
}
};
if write_vanilla_reply(&mut stream, &wire).await.is_err() {
session.close().await;
return DoorExit::ClientGone;
}
bind_quiet_and_pipe(stream, session).await
}
/// The BIND door's quiet wait + reply#2 + the data plane: await the
/// inbound accept through the session (`await_inbound` — the one
/// normative reply#2 reader), re-encode reply#2's peer to the vanilla
/// client, then pipe the pass-through plane raw (the plane owns the
/// in-band stream — `BindDataPlane` impls `AsyncRead + AsyncWrite`,
/// the pass-through to the accepted peer; the session stays the
/// teardown owner, dropped at the end).
async fn bind_quiet_and_pipe(
mut stream: TcpStream,
mut session: crate::consumer::BindSession,
) -> DoorExit {
let plane = match session.await_inbound().await {
Ok(Some(plane)) => plane,
Ok(None) => return DoorExit::PlaneEnded,
// An accept-stage refusal (reply#2's mapped code was written
// in-band; the producer's pump ended the session): the door
// closes the vanilla connection — the conversation ends where
// the in-band one did.
Err(_) => {
session.close().await;
return DoorExit::PlaneEnded;
}
};
// Reply#2 (the vanilla form): the accepted peer re-encoded.
let reply = match plane.peer {
TargetAddr::Ip(sa) => new_reply(&ReplyError::Succeeded, sa),
TargetAddr::Domain(_, port) => new_reply(
&ReplyError::Succeeded,
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port),
),
};
if write_vanilla_reply(&mut stream, &reply).await.is_err() {
session.close().await;
return DoorExit::ClientGone;
}
let _ = tokio::io::copy_bidirectional(&mut stream, &mut { plane }).await;
session.close().await;
DoorExit::PlaneEnded
}
/// Write one vanilla reply frame (the door's RFC codec at the
/// boundary — ADR-003): write + flush; a failed write is the client
/// gone.
async fn write_vanilla_reply(stream: &mut TcpStream, reply: &[u8]) -> std::io::Result<()> {
stream.write_all(reply).await?;
stream.flush().await
}