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.
This commit is contained in:
1 parent
f3c371310c
commit
b8fd75b9f3
6 files changed
+1905
-18
No files matched your search
+236
-3
@@ -58,11 +58,13 @@
|
||||
//! 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::client::{ChannelClient, ChannelOpenError};
|
||||
use alkcall::channels::manager::ChannelManager;
|
||||
use alkcall::core::types::BiStream;
|
||||
use alkcall::protocol::connection::CallConnection;
|
||||
use bytes::Bytes;
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
|
||||
|
||||
use crate::error::{SocksOpenError, SocksSessionError};
|
||||
@@ -107,6 +109,51 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// The cred-bearing front door's re-origination prologue (the
|
||||
/// token-carrying twin of [`open_and_negotiate`]): open the channel
|
||||
/// with the payload `auth_token` riding the `call.requested` payload
|
||||
/// (CF-005 precedence (1) — the producer-side dispatch resolves the
|
||||
/// token through its `ServingConfig.identity_provider` and runs the
|
||||
/// ACL gate against the mapped identity), join the halves, run the
|
||||
/// command negotiation, and reap on failure — the same phantom-channel
|
||||
/// property. `Ok` yields the channel id, the negotiated stream, and
|
||||
/// the cloned manager.
|
||||
///
|
||||
/// The token path needs the raw `call.requested` payload (`auth_token`
|
||||
/// is a payload-level field, ADR-017 §7), so the open rides
|
||||
/// `CallConnection::call_with_payload` + `adopt_channel` — the same
|
||||
/// two-step `open_channel` performs, with the token added. The
|
||||
/// client's call connection is taken once and cached in the returned
|
||||
/// client (the door's `TokenClient` owns it for the door's lifetime).
|
||||
async fn open_with_token_and_negotiate<T, F, Fut>(
|
||||
client: &crate::consumer::TokenClient,
|
||||
token: Vec<u8>,
|
||||
negotiate: F,
|
||||
) -> Result<(u32, T, ChannelManager), SocksSessionError>
|
||||
where
|
||||
F: FnOnce(Socks5Stream<BiStream>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, SocksSessionError>>,
|
||||
{
|
||||
let channel_id = client.open_with_token(token).await?;
|
||||
|
||||
let (send, recv) = client
|
||||
.manager()
|
||||
.adopt_channel(channel_id, SOCKS5_ALPN, None)
|
||||
.await
|
||||
.map_err(|e| SocksOpenError::from(ChannelOpenError::AdoptFailed(e)))?;
|
||||
let bidi = BiStream::from_joined(recv, send);
|
||||
|
||||
let negotiated = negotiate(Socks5Stream::use_stream(bidi, None, Config::default()).await?);
|
||||
|
||||
match negotiated.await {
|
||||
Ok(inner) => Ok((channel_id, inner, client.manager().clone())),
|
||||
Err(err) => {
|
||||
let _ = client.manager().teardown_channel(channel_id);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The spent-session marker (the gate review's R-2,
|
||||
/// tasks/implementation/review-protocol-core.md): [`Socks5ChannelSession
|
||||
/// ::into_inner`] / [`AssociateSession::into_inner`] return it when
|
||||
@@ -136,6 +183,11 @@ pub struct Socks5ChannelSession {
|
||||
channel_id: u32,
|
||||
manager: Option<ChannelManager>,
|
||||
inner: Option<Socks5Stream<BiStream>>,
|
||||
/// The CONNECT reply's BND.ADDR — captured at `connect` (the
|
||||
/// forked client `request()` returns it once; the front door's
|
||||
/// re-origination re-encodes the vanilla reply from it — the RFC
|
||||
/// codec at the door boundary only, ADR-003).
|
||||
reply_addr: Option<TargetAddr>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Socks5ChannelSession {
|
||||
@@ -165,15 +217,45 @@ impl Socks5ChannelSession {
|
||||
) -> Result<Self, SocksSessionError> {
|
||||
let target: TargetAddr = target.to_target_addr()?;
|
||||
let (channel_id, inner, manager) = open_and_negotiate(client, |mut inner| async move {
|
||||
inner.request(Socks5Command::TCPConnect, target).await?;
|
||||
Ok(inner)
|
||||
let reply_addr = inner.request(Socks5Command::TCPConnect, target).await?;
|
||||
Ok((reply_addr, inner))
|
||||
})
|
||||
.await?;
|
||||
let (reply_addr, inner) = inner;
|
||||
|
||||
Ok(Self {
|
||||
channel_id,
|
||||
manager: Some(manager),
|
||||
inner: Some(inner),
|
||||
reply_addr: Some(reply_addr),
|
||||
})
|
||||
}
|
||||
|
||||
/// The cred-bearing front door's CONNECT re-origination (the
|
||||
/// token-carrying twin of [`Self::connect`]): the open rides the
|
||||
/// payload `auth_token` (CF-005 precedence (1) — the producer's
|
||||
/// identity provider resolves the door's mapped identity and its
|
||||
/// scopes gate the open), then the same in-stream negotiation over
|
||||
/// the channel's `BiStream`. Error surface identical.
|
||||
pub async fn connect_with_token(
|
||||
client: &TokenClient,
|
||||
target: impl ToTargetAddr,
|
||||
token: Vec<u8>,
|
||||
) -> Result<Self, SocksSessionError> {
|
||||
let target: TargetAddr = target.to_target_addr()?;
|
||||
let (channel_id, inner, manager) =
|
||||
open_with_token_and_negotiate(client, token, |mut inner| async move {
|
||||
let reply_addr = inner.request(Socks5Command::TCPConnect, target).await?;
|
||||
Ok((reply_addr, inner))
|
||||
})
|
||||
.await?;
|
||||
let (reply_addr, inner) = inner;
|
||||
|
||||
Ok(Self {
|
||||
channel_id,
|
||||
manager: Some(manager),
|
||||
inner: Some(inner),
|
||||
reply_addr: Some(reply_addr),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,6 +265,16 @@ impl Socks5ChannelSession {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
/// The CONNECT reply's BND.ADDR — the in-band reply's bound
|
||||
/// address, captured at `connect` (the forked client `request()`
|
||||
/// returns it once). The front door's re-origination re-encodes
|
||||
/// the vanilla reply from it (the RFC codec at the door boundary
|
||||
/// only, ADR-003); `None` after the pipe was taken (the
|
||||
/// spent-session posture).
|
||||
pub fn reply_addr(&self) -> Option<TargetAddr> {
|
||||
self.reply_addr.clone()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -346,6 +438,39 @@ impl AssociateSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// The cred-bearing front door's ASSOCIATE re-origination (the
|
||||
/// token-carrying twin of [`Self::associate`]): the open rides
|
||||
/// the payload `auth_token` (CF-005 precedence (1)). The TCP door
|
||||
/// uses this only for its RFC-faithful UDP refusal probe (the
|
||||
/// door's datagram plane is the UDP door's task); the session is
|
||||
/// otherwise identical.
|
||||
pub async fn associate_with_token(
|
||||
client: &TokenClient,
|
||||
token: Vec<u8>,
|
||||
) -> Result<Self, SocksSessionError> {
|
||||
let (channel_id, inner, manager) =
|
||||
open_with_token_and_negotiate(client, token, |mut inner| async move {
|
||||
let relay_addr = inner
|
||||
.request(
|
||||
Socks5Command::UDPAssociate,
|
||||
TargetAddr::Ip(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0)),
|
||||
)
|
||||
.await?;
|
||||
Ok((relay_addr, inner))
|
||||
})
|
||||
.await?;
|
||||
let (relay_addr, inner) = inner;
|
||||
|
||||
Ok(Self {
|
||||
channel_id,
|
||||
manager: Some(manager),
|
||||
inner: Some(inner),
|
||||
reader: DatagramReader::new(),
|
||||
pending: std::collections::VecDeque::new(),
|
||||
relay_addr: Some(relay_addr),
|
||||
})
|
||||
}
|
||||
|
||||
/// The adopted channel's id (observability + correlation; one
|
||||
/// session = one channel).
|
||||
pub fn channel_id(&self) -> u32 {
|
||||
@@ -751,6 +876,35 @@ impl BindSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// The cred-bearing front door's BIND re-origination (the
|
||||
/// token-carrying twin of [`Self::bind`]): the open rides the
|
||||
/// payload `auth_token` (CF-005 precedence (1) — the producer's
|
||||
/// identity provider resolves the door's mapped identity; the
|
||||
/// BIND command gate reads `socks5:bind` from it, ADR-006), then
|
||||
/// the same in-stream BIND phase model over the channel's
|
||||
/// `BiStream`. Error surface identical.
|
||||
pub async fn bind_with_token(
|
||||
client: &TokenClient,
|
||||
target: impl ToTargetAddr,
|
||||
token: Vec<u8>,
|
||||
) -> Result<Self, SocksSessionError> {
|
||||
let target: TargetAddr = target.to_target_addr()?;
|
||||
let (channel_id, inner, manager) =
|
||||
open_with_token_and_negotiate(client, token, |mut inner| async move {
|
||||
let bind_addr = inner.request(Socks5Command::TCPBind, target).await?;
|
||||
Ok((bind_addr, inner))
|
||||
})
|
||||
.await?;
|
||||
let (bind_addr, inner) = inner;
|
||||
|
||||
Ok(Self {
|
||||
channel_id,
|
||||
manager: Some(manager),
|
||||
inner: Some(inner),
|
||||
bind_addr: Some(bind_addr),
|
||||
})
|
||||
}
|
||||
|
||||
/// The adopted channel's id (observability + correlation; one
|
||||
/// session = one channel).
|
||||
pub fn channel_id(&self) -> u32 {
|
||||
@@ -900,6 +1054,82 @@ where
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
/// The cred-bearing front door's channel surface (the
|
||||
/// token-carrying twin of a `ChannelClient`'s open path): wraps the
|
||||
/// client's channel-0 [`CallConnection`] so an open call can carry the
|
||||
/// payload `auth_token` (CF-005 precedence (1) — the producer-side
|
||||
/// dispatch resolves the token through its
|
||||
/// `ServingConfig.identity_provider` and runs the ACL gate against the
|
||||
/// resolved identity, ADR-017 §7). The client's call connection is
|
||||
/// taken once at construction (the client's own calling surface ends
|
||||
/// — a door client is a door client); the manager stays shared for
|
||||
/// `adopt_channel`/teardown.
|
||||
///
|
||||
/// The token is the credential table's *identity id* (the resolution
|
||||
/// seam's vocabulary — the SOCKS password itself never rides it, and
|
||||
/// the vanilla client never sees the identity). The door's producer
|
||||
/// resolves `token → Identity` through its
|
||||
/// `ServingConfig.identity_provider`; the identity's scopes govern the
|
||||
/// open gate and the BIND command gate exactly as any per-call opener
|
||||
/// identity does (CF-005/CF-006).
|
||||
pub struct TokenClient {
|
||||
call: Arc<CallConnection>,
|
||||
manager: ChannelManager,
|
||||
}
|
||||
|
||||
impl TokenClient {
|
||||
/// Wrap a `ChannelClient`: take its call connection (the open-op
|
||||
/// calling surface) and clone the manager. The door's
|
||||
/// token-bearing sessions then ride this client.
|
||||
pub async fn wrap(client: &ChannelClient) -> Self {
|
||||
let call = client
|
||||
.take_call_connection()
|
||||
.await
|
||||
.expect("the channel client's call connection exists until taken");
|
||||
Self {
|
||||
call,
|
||||
manager: client.manager().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The manager (adopt/teardown — the token sessions' prologue uses
|
||||
/// it; the sessions' teardown owns it per session).
|
||||
pub fn manager(&self) -> &ChannelManager {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
/// The raw open call with the `auth_token` payload (CF-005
|
||||
/// precedence (1)): the payload shape mirrors
|
||||
/// [`ChannelClient::call_open_op`]'s `call.requested` envelope with
|
||||
/// the token added (ADR-017 §7 — the hub-forwarding path's shape).
|
||||
/// Returns the adopted `channel_id` (the reply's `channel_id`
|
||||
/// extracted — the adopt is the caller's next step, mirroring
|
||||
/// `open_channel`'s two halves).
|
||||
pub async fn open_with_token(&self, token: Vec<u8>) -> Result<u32, SocksOpenError> {
|
||||
let payload = serde_json::json!({
|
||||
"operationId": OP_SOCKS5_OPEN,
|
||||
"input": Socks5Params::as_json(),
|
||||
"auth_token": String::from_utf8_lossy(&token).into_owned(),
|
||||
});
|
||||
let response = self.call.call_with_payload(payload).await;
|
||||
let out = response
|
||||
.result
|
||||
.map_err(|error| ChannelOpenError::CallFailed { error })
|
||||
.map_err(SocksOpenError::from)?;
|
||||
let channel_id = out
|
||||
.get("channel_id")
|
||||
.and_then(|v: &serde_json::Value| v.as_u64())
|
||||
.ok_or(ChannelOpenError::MissingChannelId)? as u32;
|
||||
Ok(channel_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TokenClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TokenClient").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -947,6 +1177,7 @@ mod tests {
|
||||
channel_id: 7,
|
||||
manager: None,
|
||||
inner: None,
|
||||
reply_addr: None,
|
||||
};
|
||||
let rendered = format!("{session:?}");
|
||||
assert!(rendered.contains("channel_id: 7"), "{rendered}");
|
||||
@@ -959,6 +1190,7 @@ mod tests {
|
||||
channel_id: 9,
|
||||
manager: None,
|
||||
inner: None,
|
||||
reply_addr: None,
|
||||
};
|
||||
assert!(!session.reap(), "no manager — nothing reaped");
|
||||
assert!(session.split().is_none(), "no pipe after teardown");
|
||||
@@ -973,6 +1205,7 @@ mod tests {
|
||||
channel_id: 9,
|
||||
manager: None,
|
||||
inner: None,
|
||||
reply_addr: None,
|
||||
};
|
||||
assert!(
|
||||
session.into_inner().is_err(),
|
||||
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -68,6 +68,9 @@ pub mod wire;
|
||||
#[cfg(feature = "local")]
|
||||
pub mod local;
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub mod door;
|
||||
|
||||
/// The shared test helpers (review 002 N-5): the single-sourced
|
||||
/// harness shapes, compiled into the lib test crate. The file lives
|
||||
/// under `tests/common/` so the integration suite reaches the same
|
||||
@@ -109,3 +112,8 @@ pub use wire::{addr_encoding_len, frame, frame_inner, DatagramReader, WireError,
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub use local::{bind_accept, tcp_dial, udp_egress, LocalEgress};
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub use consumer::TokenClient;
|
||||
#[cfg(feature = "local")]
|
||||
pub use door::{CredentialTable, DoorCredential, FrontDoor};
|
||||
Reference in new issue
Block a user