use crate::args::Arguments;
use crate::args::LoggingLevel;
use crate::args::PayloadFormattingKind;
use crate::args::TimestampPrecision;
use crate::conn::initialize_tcp_listener;
use crate::conn::run_accept_loop;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::time::sleep;
use tokio::time::timeout;
use tokio_modbus::ExceptionCode;
use tokio_modbus::Request as ModbusRequest;
use tokio_modbus::Response as ModbusResponse;
use tokio_modbus::client::Reader;
use tokio_modbus::server::Service as ModbusService;
use tokio_modbus::server::tcp::Server as ModbusServer;
use tokio_modbus::server::tcp::accept_tcp_connection;
const IO_TIMEOUT: Duration = Duration::from_secs(10);
const LOOPBACK: &str = "127.0.0.1:0";
const TEST_MAX_CONNECTIONS: u32 = 512;
fn test_arguments(
bind_listener_addr: SocketAddr,
remote_addr: SocketAddr,
timeout: Option<u64>,
max_connections: u32,
) -> Arguments {
Arguments {
level: LoggingLevel::Off,
bind_listener_addr,
remote_addr,
timeout,
max_connections,
formatting: PayloadFormattingKind::LowerHex,
separator: ":".to_string(),
precision: TimestampPrecision::Seconds,
}
}
async fn spawn_echo_server() -> SocketAddr {
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind echo server");
let addr = listener.local_addr().expect("echo server local_addr");
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buffer = [0u8; 4096];
loop {
match stream.read(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(read_length) => {
if stream.write_all(&buffer[0..read_length]).await.is_err() {
break;
}
}
}
}
});
}
});
addr
}
async fn spawn_proxy(remote_addr: SocketAddr) -> SocketAddr {
spawn_proxy_full(
remote_addr,
Some(IO_TIMEOUT.as_secs()),
TEST_MAX_CONNECTIONS,
)
.await
}
async fn spawn_proxy_with_timeout(remote_addr: SocketAddr, timeout: Option<u64>) -> SocketAddr {
spawn_proxy_full(remote_addr, timeout, TEST_MAX_CONNECTIONS).await
}
async fn spawn_proxy_with_limit(remote_addr: SocketAddr, max_connections: u32) -> SocketAddr {
spawn_proxy_full(remote_addr, Some(IO_TIMEOUT.as_secs()), max_connections).await
}
async fn spawn_proxy_full(
remote_addr: SocketAddr,
timeout: Option<u64>,
max_connections: u32,
) -> SocketAddr {
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind proxy");
let addr = listener.local_addr().expect("proxy local_addr");
tokio::spawn(run_accept_loop(
listener,
test_arguments(addr, remote_addr, timeout, max_connections),
));
addr
}
async fn connect(addr: SocketAddr) -> TcpStream {
timeout(IO_TIMEOUT, TcpStream::connect(addr))
.await
.expect("connect timed out")
.expect("failed to connect")
}
async fn assert_round_trip(client: &mut TcpStream, payload: &[u8]) {
timeout(IO_TIMEOUT, client.write_all(payload))
.await
.expect("write timed out")
.expect("failed to write to proxy");
let mut received = vec![0u8; payload.len()];
timeout(IO_TIMEOUT, client.read_exact(&mut received))
.await
.expect("read timed out")
.expect("failed to read echo back through proxy");
assert_eq!(
received, payload,
"payload must round-trip through the proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn relays_payload_through_remote() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy(echo_addr).await;
let mut client = connect(proxy_addr).await;
assert_round_trip(&mut client, b"Hello, MODBUS!").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn relays_multiple_sequential_messages() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy(echo_addr).await;
let mut client = connect(proxy_addr).await;
for round in 0..16u8 {
assert_round_trip(&mut client, &[round; 32]).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn handles_multiple_concurrent_clients() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy(echo_addr).await;
let mut clients = Vec::new();
for i in 0..16u8 {
clients.push(tokio::spawn(async move {
let mut client = connect(proxy_addr).await;
assert_round_trip(&mut client, &[i; 64]).await;
}));
}
for client in clients {
client.await.expect("client task panicked");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn closing_client_propagates_to_remote() {
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind remote");
let remote_addr = listener.local_addr().expect("remote local_addr");
let remote = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("remote accept");
let mut buffer = [0u8; 4096];
loop {
match stream.read(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
});
let proxy_addr = spawn_proxy(remote_addr).await;
let mut client = connect(proxy_addr).await;
timeout(IO_TIMEOUT, client.write_all(b"ping"))
.await
.expect("write timed out")
.expect("failed to write to proxy");
timeout(IO_TIMEOUT, client.shutdown())
.await
.expect("shutdown timed out")
.expect("failed to close client");
timeout(IO_TIMEOUT, remote)
.await
.expect("remote still open: proxy did not forward the client close")
.expect("remote task panicked");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn closing_remote_propagates_to_client() {
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind remote");
let remote_addr = listener.local_addr().expect("remote local_addr");
tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("remote accept");
drop(stream);
});
let proxy_addr = spawn_proxy(remote_addr).await;
let mut client = connect(proxy_addr).await;
let mut buffer = [0u8; 16];
let read_length = timeout(IO_TIMEOUT, client.read(&mut buffer))
.await
.expect("client read timed out: proxy did not forward the remote close")
.expect("client read errored");
assert_eq!(
read_length, 0,
"expected end-of-stream after the remote closed"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn half_closed_client_still_receives_response() {
const RESPONSE: &[u8] = b"RESPONSE-AFTER-HALF-CLOSE";
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind remote");
let remote_addr = listener.local_addr().expect("remote local_addr");
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("remote accept");
let mut scratch = [0u8; 1024];
loop {
match stream.read(&mut scratch).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
stream
.write_all(RESPONSE)
.await
.expect("remote failed to write response");
stream.shutdown().await.expect("remote failed to shut down");
});
let proxy_addr = spawn_proxy(remote_addr).await;
let mut client = connect(proxy_addr).await;
timeout(IO_TIMEOUT, client.write_all(b"REQUEST"))
.await
.expect("write timed out")
.expect("failed to write request");
timeout(IO_TIMEOUT, client.shutdown())
.await
.expect("shutdown timed out")
.expect("failed to half-close client");
let mut response = Vec::new();
timeout(IO_TIMEOUT, client.read_to_end(&mut response))
.await
.expect("read timed out")
.expect("failed to read response");
assert_eq!(
response, RESPONSE,
"the full remote response must arrive after a client half-close"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn relays_both_directions_concurrently() {
const LEN: usize = 1 << 20; let to_remote = vec![0xA5u8; LEN];
let to_client = vec![0x5Au8; LEN];
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind remote");
let remote_addr = listener.local_addr().expect("remote local_addr");
let server_payload = to_client.clone();
let remote = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("remote accept");
let (mut read_half, mut write_half) = stream.into_split();
let send = tokio::spawn(async move {
write_half
.write_all(&server_payload)
.await
.expect("remote failed to write");
write_half
.shutdown()
.await
.expect("remote failed to shut down");
});
let mut received = Vec::new();
read_half
.read_to_end(&mut received)
.await
.expect("remote failed to read");
send.await.expect("remote send task panicked");
received
});
let proxy_addr = spawn_proxy(remote_addr).await;
let client = connect(proxy_addr).await;
let (mut client_read, mut client_write) = client.into_split();
let client_payload = to_remote.clone();
let client_send = tokio::spawn(async move {
client_write
.write_all(&client_payload)
.await
.expect("client failed to write");
client_write
.shutdown()
.await
.expect("client failed to shut down");
});
let mut from_remote = Vec::new();
timeout(IO_TIMEOUT, client_read.read_to_end(&mut from_remote))
.await
.expect("client read timed out")
.expect("client failed to read");
client_send.await.expect("client send task panicked");
let from_client = timeout(IO_TIMEOUT, remote)
.await
.expect("remote timed out")
.expect("remote task panicked");
assert_eq!(
from_remote.len(),
LEN,
"client should receive the full remote payload"
);
assert!(
from_remote == to_client,
"remote -> client payload corrupted"
);
assert_eq!(
from_client.len(),
LEN,
"remote should receive the full client payload"
);
assert!(
from_client == to_remote,
"client -> remote payload corrupted"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bind_failure_returns_error() {
let occupier = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind occupier");
let in_use_addr = occupier.local_addr().expect("occupier local_addr");
let result = initialize_tcp_listener(test_arguments(
in_use_addr,
in_use_addr,
None,
TEST_MAX_CONNECTIONS,
))
.await;
assert!(
result.is_err(),
"binding to an in-use address should return an error, not panic"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unreachable_remote_closes_client_cleanly() {
let dead = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind to reserve a dead port");
let dead_remote_addr = dead.local_addr().expect("dead local_addr");
drop(dead);
let proxy_addr = spawn_proxy(dead_remote_addr).await;
let mut client = connect(proxy_addr).await;
let mut buffer = [0u8; 16];
let read_length = timeout(IO_TIMEOUT, client.read(&mut buffer))
.await
.expect("client read timed out: proxy did not close the connection after a failed remote connect")
.expect("client read errored");
assert_eq!(
read_length, 0,
"expected end-of-stream after the proxy failed to reach the remote"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn relays_a_real_modbus_exchange() {
const REGISTERS: [u16; 4] = [0x1111, 0x2222, 0x3333, 0x4444];
struct Service;
impl ModbusService for Service {
type Request = ModbusRequest<'static>;
type Response = ModbusResponse;
type Exception = ExceptionCode;
type Future = std::future::Ready<Result<Self::Response, Self::Exception>>;
fn call(&self, request: Self::Request) -> Self::Future {
let response = match request {
ModbusRequest::ReadHoldingRegisters(addr, cnt) => {
let start = addr as usize;
let end = start + cnt as usize;
if end <= REGISTERS.len() {
Ok(ModbusResponse::ReadHoldingRegisters(
REGISTERS[start..end].to_vec(),
))
} else {
Err(ExceptionCode::IllegalDataAddress)
}
}
_ => Err(ExceptionCode::IllegalFunction),
};
std::future::ready(response)
}
}
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind modbus server");
let modbus_addr = listener.local_addr().expect("modbus local_addr");
tokio::spawn(async move {
let server = ModbusServer::new(listener);
let new_service = |_socket_addr| Ok(Some(Service));
let on_connected = move |stream, socket_addr| async move {
accept_tcp_connection(stream, socket_addr, new_service)
};
let on_process_error = |err| eprintln!("modbus server error: {err}");
let _ = server.serve(&on_connected, on_process_error).await;
});
let proxy_addr = spawn_proxy(modbus_addr).await;
let mut ctx = tokio_modbus::client::tcp::connect(proxy_addr)
.await
.expect("failed to connect modbus client through proxy");
let registers = timeout(
IO_TIMEOUT,
ctx.read_holding_registers(0, REGISTERS.len() as u16),
)
.await
.expect("modbus read timed out")
.expect("modbus read failed")
.expect("modbus returned an exception");
assert_eq!(
registers,
REGISTERS.to_vec(),
"holding registers must round-trip through the proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn relays_a_real_http_exchange() {
const BODY: &str = "Hello through the proxy";
let server = tiny_http::Server::http("127.0.0.1:0").expect("failed to start http server");
let http_addr = server
.server_addr()
.to_ip()
.expect("http server ip address");
std::thread::spawn(move || {
if let Ok(request) = server.recv() {
let _ = request.respond(tiny_http::Response::from_string(BODY));
}
});
let proxy_addr = spawn_proxy(http_addr).await;
let mut client = connect(proxy_addr).await;
timeout(
IO_TIMEOUT,
client.write_all(b"GET / HTTP/1.1\r\nHost: proxy-test\r\nConnection: close\r\n\r\n"),
)
.await
.expect("http write timed out")
.expect("failed to send http request");
let mut response = Vec::new();
let mut buffer = [0u8; 1024];
loop {
let read_length = timeout(IO_TIMEOUT, client.read(&mut buffer))
.await
.expect("http read timed out")
.expect("failed to read http response");
if read_length == 0 {
break;
}
response.extend_from_slice(&buffer[0..read_length]);
if String::from_utf8_lossy(&response).contains(BODY) {
break;
}
}
let text = String::from_utf8_lossy(&response);
assert!(
text.starts_with("HTTP/1.1 200"),
"expected a 200 response through the proxy, got: {text}"
);
assert!(
text.contains(BODY),
"the HTTP response body must round-trip through the proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_connection_times_out_when_timeout_is_set() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy_with_timeout(echo_addr, Some(1)).await;
let mut client = connect(proxy_addr).await;
let mut buffer = [0u8; 16];
let result = timeout(IO_TIMEOUT, client.read(&mut buffer))
.await
.expect("client read timed out: the idle timeout did not fire");
match result {
Ok(0) | Err(_) => {} Ok(n) => panic!("expected the idle connection to close, but read {n} bytes"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_connection_stays_open_without_timeout() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy_with_timeout(echo_addr, None).await;
let mut client = connect(proxy_addr).await;
sleep(Duration::from_millis(1500)).await;
assert_round_trip(&mut client, b"still alive").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn active_reverse_direction_keeps_forward_direction_open() {
let listener = TcpListener::bind(LOOPBACK)
.await
.expect("failed to bind remote");
let remote_addr = listener.local_addr().expect("remote local_addr");
tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("remote accept");
let (mut read_half, mut write_half) = stream.into_split();
let reader = tokio::spawn(async move {
let mut buffer = [0u8; 64];
let read_length = read_half.read(&mut buffer).await.unwrap_or(0);
buffer[0..read_length].to_vec()
});
for _ in 0..5 {
sleep(Duration::from_millis(400)).await;
if write_half.write_all(b".").await.is_err() {
break;
}
}
let from_client = reader.await.unwrap_or_default();
let _ = write_half.write_all(&from_client).await;
let _ = write_half.shutdown().await;
});
let proxy_addr = spawn_proxy_with_timeout(remote_addr, Some(1)).await;
let mut client = connect(proxy_addr).await;
sleep(Duration::from_millis(1200)).await;
timeout(IO_TIMEOUT, client.write_all(b"PING"))
.await
.expect("write timed out")
.expect("failed to send marker");
let mut received = Vec::new();
timeout(IO_TIMEOUT, client.read_to_end(&mut received))
.await
.expect("read timed out")
.expect("failed to read");
assert!(
received.windows(4).any(|window| window == b"PING"),
"the forward direction must stay open while the reverse direction is active"
);
}
#[test]
fn timeout_argument_is_range_validated() {
use clap::Parser;
fn parse(extra: &[&str]) -> Result<Arguments, clap::Error> {
let mut argv = vec!["logged_tcp_proxy", "-b", "127.0.0.1:0", "-r", "127.0.0.1:0"];
argv.extend_from_slice(extra);
Arguments::try_parse_from(argv)
}
assert_eq!(
parse(&[]).expect("omitting --timeout should parse").timeout,
None,
);
assert_eq!(
parse(&["-t", "30"])
.expect("a normal --timeout should parse")
.timeout,
Some(30),
);
assert!(parse(&["-t", "1"]).is_ok(), "the minimum (1) is accepted");
assert!(
parse(&["-t", "3153600000"]).is_ok(),
"the maximum (~100 years) is accepted"
);
assert!(parse(&["-t", "0"]).is_err(), "0 is rejected");
assert!(
parse(&["-t", "3153600001"]).is_err(),
"above the maximum is rejected"
);
assert!(
parse(&["-t", "18446744073709551615"]).is_err(),
"u64::MAX (which would overflow the clock) is rejected"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn caps_concurrent_connections() {
let echo_addr = spawn_echo_server().await;
let proxy_addr = spawn_proxy_with_limit(echo_addr, 1).await;
let mut first = connect(proxy_addr).await;
assert_round_trip(&mut first, b"first").await;
let mut second = connect(proxy_addr).await;
timeout(IO_TIMEOUT, second.write_all(b"second"))
.await
.expect("write timed out")
.expect("failed to write");
let mut buffer = [0u8; 16];
let while_capped = timeout(Duration::from_millis(500), second.read(&mut buffer)).await;
assert!(
while_capped.is_err(),
"the second connection must not be served while the cap is reached"
);
drop(first);
let read_length = timeout(IO_TIMEOUT, second.read(&mut buffer))
.await
.expect("read timed out after a slot freed")
.expect("failed to read");
assert_eq!(
&buffer[0..read_length],
b"second",
"the second connection is served once a slot frees"
);
}
#[test]
fn max_connections_has_a_default_and_rejects_zero() {
use clap::Parser;
fn parse(extra: &[&str]) -> Result<Arguments, clap::Error> {
let mut argv = vec!["logged_tcp_proxy", "-b", "127.0.0.1:0", "-r", "127.0.0.1:0"];
argv.extend_from_slice(extra);
Arguments::try_parse_from(argv)
}
assert_eq!(
parse(&[])
.expect("default max-connections should parse")
.max_connections,
512,
);
assert_eq!(
parse(&["-m", "32"])
.expect("an explicit max-connections should parse")
.max_connections,
32,
);
assert!(parse(&["-m", "0"]).is_err(), "0 is rejected");
}
#[test]
fn accept_backoff_grows_and_caps() {
use crate::conn::ACCEPT_BACKOFF_MAX;
use crate::conn::ACCEPT_BACKOFF_MIN;
use crate::conn::next_accept_backoff;
assert!(ACCEPT_BACKOFF_MIN < ACCEPT_BACKOFF_MAX);
let mut delay = ACCEPT_BACKOFF_MIN;
let mut previous = delay;
for _ in 0..16 {
delay = next_accept_backoff(delay);
assert!(
delay >= previous,
"the backoff must not shrink while errors persist"
);
assert!(delay <= ACCEPT_BACKOFF_MAX, "the backoff is capped");
previous = delay;
}
assert_eq!(
delay, ACCEPT_BACKOFF_MAX,
"the backoff reaches and holds at the cap"
);
}