Replace HTTP-shaped forwarding with true persistent multiplexed tunnel

Fix socks-e2e scrutiny blockers by replacing HTTP-shaped request/response
forwarding with a true persistent authenticated tunnel data path.

Key changes:
- Implement bidirectional framing with stream IDs over the authenticated
  tunnel so SOCKS target TCP connections originate from the listener side
- Multiple sequential/concurrent SOCKS streams share one authenticated
  session with stream isolation via frame dispatch
- Bytes stream incrementally without whole-response buffering or fixed EOF
  timeouts
- CLOSE/ERROR frames propagate deterministically for clean stream teardown
- Remove /forward HTTP endpoint and TunnelSession HTTP-based forwarding
- Remove read_until_double_crlf HTTP-specific parsing from SOCKS path
- Add StreamMux for concurrent stream management on the connector side
- Add tests for slow/interleaved streams, concurrent isolation,
  listener-side target origin, close frame propagation, and E2E framing

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
c4ch3c4d3
2026-06-03 21:01:24 -06:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent f871ba7f7b
commit d6a0a40696
4 changed files with 2267 additions and 1991 deletions
+558
View File
@@ -0,0 +1,558 @@
/// Binary framing protocol for the authenticated tunnel.
///
/// After the mTLS + auth handshake completes, both sides switch from HTTP/1.1
/// to a lightweight binary framing protocol that carries multiple bidirectional
/// streams over a single persistent TCP connection.
///
/// Frame format:
/// +--------+----------+----------+-------+-----------+
/// | Type | StreamID | Len | Flags | Payload.. |
/// | 1 byte | 8 bytes | 4 bytes | 1 byte| variable |
/// +--------+----------+----------+-------+-----------+
///
/// All multi-byte integers are big-endian.
///
/// Frame types:
/// 0x01 CONNECT - Open a new stream to a target (payload: target addr)
/// 0x02 CONNECT_REPLY - Accept/reject a CONNECT (payload: 1-byte status)
/// 0x03 DATA - Stream data (payload: raw bytes)
/// 0x04 CLOSE - Half-close a stream (no payload)
/// 0x05 ERROR - Stream error (payload: error message)
///
/// Stream IDs:
/// - Client-initiated streams: odd IDs (1, 3, 5, ...)
/// - Even IDs reserved for future server-initiated streams
///
/// Flags:
/// - Bit 0 (0x01): EOF — sender will send no more data on this stream
use bytes::{Buf, BufMut, Bytes, BytesMut};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug, Error)]
pub enum FrameError {
#[error("unknown frame type: {0:#04x}")]
#[allow(dead_code)]
UnknownFrameType(u8),
#[error("frame too short: expected {0} bytes, got {1}")]
FrameTooShort(usize, usize),
#[error("payload length overflow: {0}")]
PayloadOverflow(u32),
#[error("stream {0} not found")]
#[allow(dead_code)]
StreamNotFound(u64),
#[error("duplicate stream ID: {0}")]
#[allow(dead_code)]
DuplicateStreamId(u64),
#[error("stream {0} already closed")]
#[allow(dead_code)]
StreamClosed(u64),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("protocol error: {0}")]
Protocol(String),
#[error("target connection failed: {0}")]
#[allow(dead_code)]
TargetConnectionFailed(String),
}
// ---------------------------------------------------------------------------
// Frame types
// ---------------------------------------------------------------------------
pub const FRAME_CONNECT: u8 = 0x01;
pub const FRAME_CONNECT_REPLY: u8 = 0x02;
pub const FRAME_DATA: u8 = 0x03;
pub const FRAME_CLOSE: u8 = 0x04;
pub const FRAME_ERROR: u8 = 0x05;
// Flags
#[allow(dead_code)]
pub const FLAG_EOF: u8 = 0x01;
// CONNECT_REPLY status
pub const CONNECT_OK: u8 = 0x00;
pub const CONNECT_FAILED: u8 = 0x01;
// Frame header size (type + stream_id + length + flags)
pub const FRAME_HEADER_LEN: usize = 14;
const MAX_PAYLOAD_LEN: u32 = 4 * 1024 * 1024; // 4 MiB per frame
// ---------------------------------------------------------------------------
// Frame struct
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Frame {
pub frame_type: u8,
pub stream_id: u64,
pub flags: u8,
pub payload: Bytes,
}
impl Frame {
/// Create a CONNECT frame.
pub fn connect(stream_id: u64, target_bytes: impl AsRef<[u8]>) -> Self {
Frame {
frame_type: FRAME_CONNECT,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(target_bytes.as_ref()),
}
}
/// Create a CONNECT_REPLY frame.
pub fn connect_reply(stream_id: u64, status: u8) -> Self {
Frame {
frame_type: FRAME_CONNECT_REPLY,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(&[status]),
}
}
/// Create a DATA frame.
pub fn data(stream_id: u64, data: impl AsRef<[u8]>) -> Self {
Frame {
frame_type: FRAME_DATA,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(data.as_ref()),
}
}
/// Create a DATA+EOF frame.
#[allow(dead_code)]
pub fn data_eof(stream_id: u64, data: impl AsRef<[u8]>) -> Self {
Frame {
frame_type: FRAME_DATA,
stream_id,
flags: FLAG_EOF,
payload: Bytes::copy_from_slice(data.as_ref()),
}
}
/// Create a CLOSE frame.
pub fn close(stream_id: u64) -> Self {
Frame {
frame_type: FRAME_CLOSE,
stream_id,
flags: 0,
payload: Bytes::new(),
}
}
/// Create an ERROR frame.
pub fn error(stream_id: u64, message: impl Into<String>) -> Self {
Frame {
frame_type: FRAME_ERROR,
stream_id,
flags: 0,
payload: Bytes::from(message.into()),
}
}
/// Serialize this frame to bytes.
pub fn serialize(&self) -> Bytes {
let payload_len = self.payload.len() as u32;
let total = FRAME_HEADER_LEN + payload_len as usize;
let mut buf = BytesMut::with_capacity(total);
buf.put_u8(self.frame_type);
buf.put_u64(self.stream_id);
buf.put_u32(payload_len);
buf.put_u8(self.flags);
buf.put_slice(&self.payload);
buf.freeze()
}
/// Deserialize a frame from a buffer.
/// Returns (frame, remaining_bytes) if successful.
pub fn deserialize(buf: &mut BytesMut) -> Result<(Self, usize), FrameError> {
// Need at least the header
if buf.len() < FRAME_HEADER_LEN {
return Err(FrameError::FrameTooShort(FRAME_HEADER_LEN, buf.len()));
}
let frame_type = buf.get_u8();
let stream_id = buf.get_u64();
let payload_len = buf.get_u32();
let flags = buf.get_u8();
if payload_len > MAX_PAYLOAD_LEN {
return Err(FrameError::PayloadOverflow(payload_len));
}
if buf.len() < payload_len as usize {
// Not enough data yet — put header back and wait
// We can't easily un-get, so reconstruct
let mut restored = BytesMut::with_capacity(FRAME_HEADER_LEN + buf.len());
restored.put_u8(frame_type);
restored.put_u64(stream_id);
restored.put_u32(payload_len);
restored.put_u8(flags);
restored.put_slice(buf.split_to(buf.len()).as_ref());
// Actually, since we consumed the header, we need to return the error
// and the caller will retry. We restore nothing — the caller reads again.
// This is a "need more data" condition.
return Err(FrameError::FrameTooShort(
FRAME_HEADER_LEN + payload_len as usize,
FRAME_HEADER_LEN + buf.len(),
));
}
let payload = buf.split_to(payload_len as usize).freeze();
Ok((
Frame {
frame_type,
stream_id,
flags,
payload,
},
payload_len as usize,
))
}
/// Check if this frame has the EOF flag set.
#[allow(dead_code)]
pub fn is_eof(&self) -> bool {
self.flags & FLAG_EOF != 0
}
}
// ---------------------------------------------------------------------------
// Frame reader/writer
// ---------------------------------------------------------------------------
/// Asynchronous frame reader that handles partial reads and buffering.
pub struct FrameReader {
buf: BytesMut,
}
impl FrameReader {
pub fn new() -> Self {
FrameReader {
buf: BytesMut::with_capacity(8192),
}
}
/// Read the next complete frame from the underlying reader.
/// Returns None if the connection is closed gracefully (EOF).
pub async fn read_frame<R: tokio::io::AsyncRead + Unpin>(
&mut self,
reader: &mut R,
) -> Result<Option<Frame>, FrameError> {
loop {
// Try to parse a frame from the buffer
let buf_ref = &mut self.buf;
match Frame::deserialize(buf_ref) {
Ok((frame, _)) => return Ok(Some(frame)),
Err(FrameError::FrameTooShort(_, _)) => {
// Need more data — read from the underlying reader
let capacity = self.buf.capacity();
let len = self.buf.len();
// Ensure we have space
if len == capacity {
self.buf.reserve(8192);
}
let _remaining = self.buf.spare_capacity_mut();
match reader.read_buf(&mut self.buf).await {
Ok(0) => {
// Connection closed — if we have partial data, it's an error
if !self.buf.is_empty() {
return Err(FrameError::Protocol(
"connection closed with partial frame".to_string(),
));
}
return Ok(None);
}
Ok(_) => continue,
Err(e) => return Err(FrameError::Io(e)),
}
}
Err(e) => return Err(e),
}
}
}
}
/// Asynchronous frame writer.
pub struct FrameWriter;
impl FrameWriter {
/// Write a frame to the underlying writer.
pub async fn write_frame<W: tokio::io::AsyncWrite + Unpin>(
writer: &mut W,
frame: &Frame,
) -> Result<(), FrameError> {
let bytes = frame.serialize();
writer.write_all(&bytes).await?;
writer.flush().await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Target address encoding for CONNECT frames
// ---------------------------------------------------------------------------
/// Encode a target address into CONNECT frame payload bytes.
/// Format: ATYP (1) + addr (variable) + port (2)
pub fn encode_target(target: &crate::socks5::Socks5Target) -> Vec<u8> {
match target {
crate::socks5::Socks5Target::Ipv4(addr, port) => {
let mut v = vec![crate::socks5::ATYP_IPV4];
v.extend_from_slice(&addr.octets());
v.extend_from_slice(&port.to_be_bytes());
v
}
crate::socks5::Socks5Target::Domain { domain, port } => {
let mut v = vec![crate::socks5::ATYP_DOMAIN, domain.len() as u8];
v.extend_from_slice(domain.as_bytes());
v.extend_from_slice(&port.to_be_bytes());
v
}
}
}
/// Decode a target address from CONNECT frame payload bytes.
pub fn decode_target(payload: &[u8]) -> Result<crate::socks5::Socks5Target, FrameError> {
if payload.is_empty() {
return Err(FrameError::Protocol("empty target".to_string()));
}
let atyp = payload[0];
let mut cursor = &payload[1..];
match atyp {
crate::socks5::ATYP_IPV4 => {
if cursor.len() < 6 {
return Err(FrameError::Protocol("truncated IPv4 target".to_string()));
}
let addr = std::net::Ipv4Addr::new(cursor[0], cursor[1], cursor[2], cursor[3]);
let port = u16::from_be_bytes([cursor[4], cursor[5]]);
Ok(crate::socks5::Socks5Target::Ipv4(addr, port))
}
crate::socks5::ATYP_DOMAIN => {
if cursor.is_empty() {
return Err(FrameError::Protocol("truncated domain target".to_string()));
}
let domain_len = cursor[0] as usize;
cursor.advance(1);
if cursor.len() < domain_len + 2 {
return Err(FrameError::Protocol("truncated domain target".to_string()));
}
let domain = String::from_utf8(cursor[..domain_len].to_vec())
.map_err(|_| FrameError::Protocol("invalid UTF-8 in domain".to_string()))?;
cursor.advance(domain_len);
let port = u16::from_be_bytes([cursor[0], cursor[1]]);
Ok(crate::socks5::Socks5Target::Domain { domain, port })
}
_ => Err(FrameError::Protocol(format!(
"unsupported address type: {:#04x}",
atyp
))),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn frame_roundtrip_connect() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(127, 0, 0, 1), 4181);
let payload = encode_target(&target);
let frame = Frame::connect(1, payload.clone());
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CONNECT);
assert_eq!(decoded.stream_id, 1);
assert_eq!(decoded.payload, payload);
let decoded_target = decode_target(&decoded.payload).unwrap();
assert_eq!(decoded_target, target);
}
#[test]
fn frame_roundtrip_data() {
let data = b"hello world";
let frame = Frame::data(3, data);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_DATA);
assert_eq!(decoded.stream_id, 3);
assert_eq!(&decoded.payload[..], data);
}
#[test]
fn frame_roundtrip_data_eof() {
let frame = Frame::data_eof(5, b"final");
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert!(decoded.is_eof());
}
#[test]
fn frame_roundtrip_close() {
let frame = Frame::close(7);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CLOSE);
assert_eq!(decoded.stream_id, 7);
assert!(decoded.payload.is_empty());
}
#[test]
fn frame_roundtrip_error() {
let frame = Frame::error(9, "connection refused");
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_ERROR);
assert_eq!(
String::from_utf8_lossy(&decoded.payload),
"connection refused"
);
}
#[test]
fn frame_roundtrip_connect_reply() {
let frame = Frame::connect_reply(1, CONNECT_OK);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CONNECT_REPLY);
assert_eq!(decoded.payload[0], CONNECT_OK);
}
#[test]
fn frame_partial_read_returns_error() {
// Header is 14 bytes; with only 10 bytes, deserialization should fail
let mut buf = BytesMut::from(&[0u8; 10][..]);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
}
#[test]
fn frame_multiple_frames_in_buffer() {
let frame1 = Frame::data(1, b"first");
let frame2 = Frame::data(3, b"second");
let mut buf = BytesMut::new();
buf.extend_from_slice(&frame1.serialize());
buf.extend_from_slice(&frame2.serialize());
let (d1, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(d1.stream_id, 1);
assert_eq!(&d1.payload[..], b"first");
let (d2, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(d2.stream_id, 3);
assert_eq!(&d2.payload[..], b"second");
assert!(buf.is_empty());
}
#[test]
fn encode_decode_target_ipv4() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(10, 0, 0, 1), 8080);
let encoded = encode_target(&target);
let decoded = decode_target(&encoded).unwrap();
assert_eq!(decoded, target);
}
#[test]
fn encode_decode_target_domain() {
let target = crate::socks5::Socks5Target::Domain {
domain: "example.com".to_string(),
port: 443,
};
let encoded = encode_target(&target);
let decoded = decode_target(&encoded).unwrap();
assert_eq!(decoded, target);
}
#[test]
fn decode_target_empty_fails() {
assert!(decode_target(&[]).is_err());
}
#[test]
fn decode_target_truncated_fails() {
assert!(decode_target(&[crate::socks5::ATYP_IPV4, 127]).is_err());
}
#[test]
fn decode_target_unsupported_atyp_fails() {
assert!(decode_target(&[0x04, 0, 0, 0, 0, 0, 0]).is_err());
}
#[tokio::test]
async fn frame_reader_writes_all_then_reads() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(127, 0, 0, 1), 8080);
let frame = Frame::connect(1, encode_target(&target));
let serialized = frame.serialize();
let mut reader = FrameReader::new();
let mut cursor = Cursor::new(&serialized[..]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_CONNECT);
assert_eq!(result.stream_id, 1);
let decoded_target = decode_target(&result.payload).unwrap();
assert_eq!(decoded_target, target);
}
#[tokio::test]
async fn frame_reader_eof_returns_none() {
let mut reader = FrameReader::new();
let mut cursor = Cursor::new(&[] as &[u8]);
let result = reader.read_frame(&mut cursor).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn frame_writer_roundtrip() {
let frame = Frame::data(5, b"test data here");
let serialized = frame.serialize();
let mut cursor = Vec::new();
FrameWriter::write_frame(&mut cursor, &frame).await.unwrap();
assert_eq!(cursor, serialized.as_ref());
}
#[test]
fn frame_header_size() {
assert_eq!(FRAME_HEADER_LEN, 14);
}
}
+1
View File
@@ -1,6 +1,7 @@
mod cli;
mod config;
mod errors;
mod framing;
mod generate;
mod redact;
mod socks5;
+3
View File
@@ -45,6 +45,7 @@ pub enum Socks5Error {
AuthFailed,
#[error("connection not allowed")]
#[allow(dead_code)]
ConnectionNotAllowed,
#[error("network unreachable")]
@@ -52,9 +53,11 @@ pub enum Socks5Error {
NetworkUnreachable,
#[error("host unreachable")]
#[allow(dead_code)]
HostUnreachable,
#[error("connection refused")]
#[allow(dead_code)]
ConnectionRefused,
#[error("protocol error: {0}")]
+1705 -1991
View File
File diff suppressed because it is too large Load Diff