review-003 N-8: the worthwhile residue probed — the BIND Domain-reply wire helper + table, the refusal-recode unit pins, the serve() wrappers, the len/is_empty accessors, the bridge later-batch garbage
This commit is contained in:
1 parent
b43bf5462b
commit
b43cbd24ba
3 files changed
+293
-7
No files matched your search
+163
-7
@@ -639,7 +639,23 @@ async fn pipe_bind_plane(
|
||||
session.close().await;
|
||||
return DoorExit::PlaneEnded;
|
||||
};
|
||||
let wire = match &bind_addr {
|
||||
let wire = bind_reply1_wire(&bind_addr);
|
||||
if write_vanilla_reply(&mut stream, &wire).await.is_err() {
|
||||
session.close().await;
|
||||
return DoorExit::ClientGone;
|
||||
}
|
||||
|
||||
bind_quiet_and_pipe(stream, session).await
|
||||
}
|
||||
|
||||
/// Reply#1's vanilla wire form, re-encoded from the in-band reply's
|
||||
/// advertised address: an IP BND.ADDR rides the codec (`new_reply`);
|
||||
/// a Domain BND.ADDR is the hand-written domain frame (the forked
|
||||
/// codec's `new_reply` takes a `SocketAddr` only — the domain form
|
||||
/// has no codec path, so the frame is assembled here; the byte shape
|
||||
/// is pinned by the unit table, review 003 N-8).
|
||||
fn bind_reply1_wire(bind_addr: &TargetAddr) -> Vec<u8> {
|
||||
match bind_addr {
|
||||
TargetAddr::Ip(sa) => new_reply(&ReplyError::Succeeded, *sa),
|
||||
TargetAddr::Domain(name, port) => {
|
||||
let mut wire = vec![
|
||||
@@ -653,13 +669,7 @@ async fn pipe_bind_plane(
|
||||
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
|
||||
@@ -1178,3 +1188,149 @@ mod tests {
|
||||
feeder.await.expect("feeder");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod refusal_and_wire_unit_tests {
|
||||
use super::*;
|
||||
use crate::SocksOpenError;
|
||||
use alkcall::channels::client::ChannelOpenError;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// A connected TcpStream pair (the loopback listener shape —
|
||||
/// `TcpStream` has no `pair` constructor; the listener dial is
|
||||
/// the portable way).
|
||||
async fn socket_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let client = tokio::net::TcpStream::connect(addr).await.expect("connect");
|
||||
let (server, _peer) = listener.accept().await.expect("accept");
|
||||
(client, server)
|
||||
}
|
||||
|
||||
/// The BIND reply#1 wire table (review 003 N-8: the Domain arm was
|
||||
/// hand-written wire code with no test): the IP form decodes
|
||||
/// through the forked codec (`new_reply`'s own table covers it);
|
||||
/// the Domain form's bytes are pinned exactly — VER, REP, RSV,
|
||||
/// ATYP=domain, len, name, port.
|
||||
#[test]
|
||||
fn bind_reply1_wire_forms() {
|
||||
let ip = std::net::SocketAddr::from(([127, 0, 0, 1], 2121));
|
||||
let ip_wire = bind_reply1_wire(&TargetAddr::Ip(ip));
|
||||
assert_eq!(ip_wire[0], consts::SOCKS5_VERSION);
|
||||
assert_eq!(ip_wire[1], consts::SOCKS5_REPLY_SUCCEEDED);
|
||||
assert_eq!(ip_wire[3], consts::SOCKS5_ADDR_TYPE_IPV4);
|
||||
assert_eq!(&ip_wire[4..8], &[127, 0, 0, 1]);
|
||||
assert_eq!(&ip_wire[8..10], &2121u16.to_be_bytes());
|
||||
|
||||
let domain_wire = bind_reply1_wire(&TargetAddr::Domain("bind.test".into(), 2121));
|
||||
let expected = {
|
||||
let mut v = vec![
|
||||
consts::SOCKS5_VERSION,
|
||||
consts::SOCKS5_REPLY_SUCCEEDED,
|
||||
0x00,
|
||||
consts::SOCKS5_ADDR_TYPE_DOMAIN_NAME,
|
||||
9,
|
||||
];
|
||||
v.extend_from_slice(b"bind.test");
|
||||
v.extend_from_slice(&2121u16.to_be_bytes());
|
||||
v
|
||||
};
|
||||
assert_eq!(domain_wire, expected, "the domain BND.ADDR frame");
|
||||
}
|
||||
|
||||
/// `write_refusal_reply`'s non-reply-code arm (review 003 N-8): an
|
||||
/// open/transport-shaped session error stays a bare close — no
|
||||
/// bytes are written to the vanilla client.
|
||||
#[tokio::test]
|
||||
async fn write_refusal_reply_skips_non_reply_code_shapes() {
|
||||
let (mut client, mut server) = socket_pair().await;
|
||||
let open_err =
|
||||
SocksSessionError::Open(SocksOpenError::Open(ChannelOpenError::CallFailed {
|
||||
error: CallError::new("channel:open_failed", "refused", false),
|
||||
}));
|
||||
write_refusal_reply(&mut server, &open_err).await;
|
||||
// Nothing written: the client's read finds no bytes (the bare
|
||||
// close is the door's caller's act — the helper itself is
|
||||
// silent). A short window proves no reply bytes exist.
|
||||
let mut probe = [0u8; 16];
|
||||
let n = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
client.read(&mut probe),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(n, Err(_) | Ok(Ok(0))),
|
||||
"no reply bytes for a non-reply-code error: {n:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal re-encode's wire shape (the F-1 fix's helper,
|
||||
/// byte-pinned end to end): a reply-code-shaped session error
|
||||
/// reaches the vanilla client as the RFC reply with the code +
|
||||
/// the placeholder BND.ADDR.
|
||||
#[tokio::test]
|
||||
async fn write_refusal_reply_encodes_the_mapped_code() {
|
||||
let (mut client, mut server) = socket_pair().await;
|
||||
let refused =
|
||||
SocksSessionError::Rfc(SocksError::ReplyError(ReplyError::ConnectionNotAllowed));
|
||||
write_refusal_reply(&mut server, &refused).await;
|
||||
let mut wire = [0u8; 10];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read_exact(&mut wire),
|
||||
)
|
||||
.await
|
||||
.expect("the reply arrives")
|
||||
.expect("read");
|
||||
assert_eq!(wire[0], consts::SOCKS5_VERSION);
|
||||
assert_eq!(wire[1], consts::SOCKS5_REPLY_CONNECTION_NOT_ALLOWED);
|
||||
assert_eq!(&wire[4..8], &[0, 0, 0, 0], "the placeholder BND.ADDR");
|
||||
assert_eq!(&wire[8..10], &0u16.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bridge_later_batch_tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// A decode failure in a *later* batch frame (review 003 N-8's
|
||||
/// residual: the unit tests pinned the happy shapes — a garbage
|
||||
/// frame following a good one in the same chunk surfaces the
|
||||
/// error, the good frame is NOT silently delivered first).
|
||||
#[tokio::test]
|
||||
async fn garbage_in_a_later_batch_frame_fails_loud() {
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
let (mut channel_read, _channel_write) = tokio::io::split(reader);
|
||||
let addr = TargetAddr::Ip("10.0.0.7:53".parse().expect("addr"));
|
||||
let mut wire = crate::wire::frame(&addr, b"good").unwrap().to_vec();
|
||||
// A later frame whose length field promises more bytes than
|
||||
// the chunk carries, then the stream ends — the reader's
|
||||
// state machine fails loud on the truncated later frame.
|
||||
wire.extend_from_slice(&[0xff, 0xff]);
|
||||
wire.extend_from_slice(b"short");
|
||||
let mut bridge = InbandBridge::new();
|
||||
writer.write_all(&wire).await.expect("feed");
|
||||
writer.flush().await.expect("flush");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let _ = writer.shutdown().await;
|
||||
|
||||
let first = bridge.recv(&mut channel_read).await;
|
||||
assert!(
|
||||
first.is_err() || matches!(&first, Ok(Some((src, payload))) if &payload[..] == b"good"),
|
||||
"the first frame or the loud error: {first:?}"
|
||||
);
|
||||
if matches!(first, Ok(Some(_))) {
|
||||
// The good frame delivered, the truncated later frame
|
||||
// fails loud at EOF (the reader's mid-frame state).
|
||||
let second = bridge.recv(&mut channel_read).await;
|
||||
assert!(
|
||||
second.is_err(),
|
||||
"the truncated later batch frame fails loud: {second:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,14 @@ impl VanillaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dial without the panic (the `serve` wrapper's bind races
|
||||
/// the test's connect — the retry loop's probe).
|
||||
async fn try_dial(addr: SocketAddr) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
stream: TcpStream::connect(addr).await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Greeting `05 02 00` (password auth offered — the cred-bearing
|
||||
/// door's shape) → the method-selection read.
|
||||
async fn greet_password(&mut self) -> u8 {
|
||||
@@ -1018,3 +1026,79 @@ async fn associate_session_with_token_round_trip() {
|
||||
);
|
||||
session.shutdown().await.expect("shutdown");
|
||||
}
|
||||
|
||||
/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on`
|
||||
/// — the bind-then-serve wrapper round-trips too; the NoAuth raw-pipe
|
||||
/// shape, the port probed up front so the wrapper's own bind lands on
|
||||
/// it).
|
||||
#[tokio::test]
|
||||
async fn front_door_serve_wrapper_round_trips() {
|
||||
let echo = echo_tcp_server().await;
|
||||
let dial = alksocks::tcp_dial();
|
||||
let topo = harness::wire_consumer_side_with(dial, None, None, None).await;
|
||||
|
||||
// A free loopback port, probed up front (the door binds exactly
|
||||
// it — the wrapper's own bind decision). The connect retries:
|
||||
// the wrapper's bind races the probe's teardown (the OS may hold
|
||||
// the freed port briefly).
|
||||
let mut addr = None;
|
||||
for _ in 0..50 {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.expect("probe bind");
|
||||
let candidate = probe.local_addr().expect("probe addr");
|
||||
drop(probe);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
if TcpStream::connect(candidate).await.is_err() {
|
||||
addr = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let addr = addr.expect("a genuinely free port");
|
||||
|
||||
let door = FrontDoor::builder()
|
||||
.bind(addr)
|
||||
.build()
|
||||
.expect("the door builds");
|
||||
let serve = tokio::spawn({
|
||||
let client = Arc::clone(&topo.consumer);
|
||||
async move { door.serve(client).await.expect("the door serves") }
|
||||
});
|
||||
|
||||
let mut vanilla = loop {
|
||||
if let Ok(v) = VanillaClient::try_dial(addr).await {
|
||||
break v;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let method = timeout(BOUND, vanilla.greet_no_auth())
|
||||
.await
|
||||
.expect("greeting");
|
||||
assert_eq!(method, consts::SOCKS5_AUTH_METHOD_NONE);
|
||||
let code = timeout(BOUND, vanilla.connect_v4(echo))
|
||||
.await
|
||||
.expect("CONNECT");
|
||||
assert_eq!(code, consts::SOCKS5_REPLY_SUCCEEDED);
|
||||
vanilla.stream.write_all(b"served").await.unwrap();
|
||||
let mut buf = [0u8; 6];
|
||||
timeout(BOUND, vanilla.stream.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("echo")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"served");
|
||||
serve.abort();
|
||||
}
|
||||
|
||||
/// The credential table's observability accessors (review 003 N-8:
|
||||
/// `len`/`is_empty` had no caller — one probe pins both).
|
||||
#[test]
|
||||
fn credential_table_len_and_is_empty_accessors() {
|
||||
let empty = alksocks::CredentialTable::new(vec![]);
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.len(), 0);
|
||||
let one = alksocks::CredentialTable::new(vec![alksocks::DoorCredential {
|
||||
username: "u".to_string(),
|
||||
password: "p".to_string(),
|
||||
identity: harness::consumer_identity(),
|
||||
}]);
|
||||
assert!(!one.is_empty());
|
||||
assert_eq!(one.len(), 1);
|
||||
}
|
||||
@@ -455,3 +455,49 @@ async fn fresh_association_succeeds_after_teardown() {
|
||||
assert_eq!(payload, b"fresh");
|
||||
serve2.abort();
|
||||
}
|
||||
|
||||
/// The `serve()` wrapper (review 003 N-8: every test drove `serve_on`
|
||||
/// — the bind-then-serve wrapper round-trips too; the port is
|
||||
/// configured explicitly so the vanilla client can address the
|
||||
/// relay).
|
||||
#[tokio::test]
|
||||
async fn udp_door_serve_wrapper_round_trips() {
|
||||
init_trace();
|
||||
let echo_addr = udp_echo_server().await;
|
||||
let topo = harness::wire_consumer_side_with(
|
||||
echo_dial(),
|
||||
Some(alksocks::udp_egress()),
|
||||
None,
|
||||
Some(harness::consumer_identity()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// A free loopback port, probed up front (the door binds exactly
|
||||
// it — the wrapper's own bind decision).
|
||||
let probe = UdpSocket::bind("127.0.0.1:0").await.expect("probe bind");
|
||||
let relay_addr = probe.local_addr().expect("probe addr");
|
||||
drop(probe);
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client socket");
|
||||
let door = UdpFrontDoor::builder()
|
||||
.bind(relay_addr)
|
||||
.build()
|
||||
.expect("the door builds");
|
||||
let _serve = tokio::spawn({
|
||||
let client = Arc::clone(&topo.consumer);
|
||||
async move { door.serve(client).await.expect("the door serves") }
|
||||
});
|
||||
|
||||
client
|
||||
.send_to(&rfc_datagram(echo_addr, b"wrapped"), relay_addr)
|
||||
.await
|
||||
.expect("vanilla sendto the relay");
|
||||
let mut rx = vec![0u8; 65535];
|
||||
let (n, from) = timeout(BOUND, client.recv_from(&mut rx))
|
||||
.await
|
||||
.expect("reply within bound")
|
||||
.expect("recv");
|
||||
assert_eq!(from, relay_addr, "the reply comes from the wrapper's bind");
|
||||
let (_, payload) = parse_rfc_datagram(&rx[..n]);
|
||||
assert_eq!(payload, b"wrapped");
|
||||
}
|
||||
Reference in new issue
Block a user