feat: add port 0 (random port) support for gateway security

When --port 0 is passed, the OS assigns a random available ephemeral
port (typically 49152-65535). The actual port is resolved after binding
and used for all log output and tunnel forwarding.

This prevents port-scanning attacks against a known fixed port.

Changes:
  src/gateway/mod.rs — bind first, extract actual_port from listener,
    use actual_port for addr formatting and tunnel.start()
  src/main.rs — update CLI help text, conditional log for port=0

8 new edge case tests:
  - port_zero_binds_to_random_port
  - port_zero_assigns_different_ports
  - port_zero_assigns_high_port
  - specific_port_binds_exactly
  - actual_port_matches_addr_format
  - port_zero_listener_accepts_connections
  - duplicate_specific_port_fails
  - tunnel_gets_actual_port_not_zero

943 tests passing, 0 clippy warnings, cargo fmt clean
This commit is contained in:
argenis de la rosa 2026-02-14 01:21:55 -05:00
parent b2aff60722
commit c8d4ceee71
2 changed files with 115 additions and 7 deletions

View file

@ -21,8 +21,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
);
}
let addr = format!("{host}:{port}");
let listener = TcpListener::bind(&addr).await?;
let listener = TcpListener::bind(format!("{host}:{port}")).await?;
let actual_port = listener.local_addr()?.port();
let addr = format!("{host}:{actual_port}");
let provider: Arc<dyn Provider> = Arc::from(providers::create_provider(
config.default_provider.as_deref().unwrap_or("openrouter"),
@ -59,7 +60,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
if let Some(ref tun) = tunnel {
println!("🔗 Starting {} tunnel...", tun.name());
match tun.start(host, port).await {
match tun.start(host, actual_port).await {
Ok(url) => {
println!("🌐 Tunnel active: {url}");
tunnel_url = Some(url);
@ -312,6 +313,111 @@ async fn send_response(
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener as TokioListener;
// ── Port allocation tests ────────────────────────────────
#[tokio::test]
async fn port_zero_binds_to_random_port() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual = listener.local_addr().unwrap().port();
assert_ne!(actual, 0, "OS must assign a non-zero port");
assert!(actual > 0, "Actual port must be positive");
}
#[tokio::test]
async fn port_zero_assigns_different_ports() {
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let l2 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let p1 = l1.local_addr().unwrap().port();
let p2 = l2.local_addr().unwrap().port();
assert_ne!(p1, p2, "Two port-0 binds should get different ports");
}
#[tokio::test]
async fn port_zero_assigns_high_port() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual = listener.local_addr().unwrap().port();
// OS typically assigns ephemeral ports >= 1024
assert!(
actual >= 1024,
"Random port {actual} should be >= 1024 (unprivileged)"
);
}
#[tokio::test]
async fn specific_port_binds_exactly() {
// Find a free port first via port 0, then rebind to it
let tmp = TokioListener::bind("127.0.0.1:0").await.unwrap();
let free_port = tmp.local_addr().unwrap().port();
drop(tmp);
let listener = TokioListener::bind(format!("127.0.0.1:{free_port}"))
.await
.unwrap();
let actual = listener.local_addr().unwrap().port();
assert_eq!(actual, free_port, "Specific port bind must match exactly");
}
#[tokio::test]
async fn actual_port_matches_addr_format() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
let addr = format!("127.0.0.1:{actual_port}");
assert!(
addr.starts_with("127.0.0.1:"),
"Addr format must include host"
);
assert!(
!addr.ends_with(":0"),
"Addr must not contain port 0 after binding"
);
}
#[tokio::test]
async fn port_zero_listener_accepts_connections() {
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
// Spawn a client that connects
let client = tokio::spawn(async move {
tokio::net::TcpStream::connect(format!("127.0.0.1:{actual_port}"))
.await
.unwrap()
});
// Accept the connection
let (stream, _peer) = listener.accept().await.unwrap();
assert!(stream.peer_addr().is_ok());
client.await.unwrap();
}
#[tokio::test]
async fn duplicate_specific_port_fails() {
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
let port = l1.local_addr().unwrap().port();
// Try to bind the same port while l1 is still alive
let result = TokioListener::bind(format!("127.0.0.1:{port}")).await;
assert!(result.is_err(), "Binding an already-used port must fail");
}
#[tokio::test]
async fn tunnel_gets_actual_port_not_zero() {
// Simulate what run_gateway does: bind port 0, extract actual port
let port: u16 = 0;
let host = "127.0.0.1";
let listener = TokioListener::bind(format!("{host}:{port}")).await.unwrap();
let actual_port = listener.local_addr().unwrap().port();
// This is the port that would be passed to tun.start(host, actual_port)
assert_ne!(actual_port, 0, "Tunnel must receive actual port, not 0");
assert!(
actual_port >= 1024,
"Tunnel port {actual_port} must be unprivileged"
);
}
// ── extract_header tests ─────────────────────────────────
#[test]
fn extract_header_finds_value() {

View file

@ -69,7 +69,7 @@ enum Commands {
/// Start the gateway server (webhooks, websockets)
Gateway {
/// Port to listen on
/// Port to listen on (use 0 for random available port)
#[arg(short, long, default_value = "8080")]
port: u16,
@ -234,9 +234,11 @@ async fn main() -> Result<()> {
} => agent::run(config, message, provider, model, temperature).await,
Commands::Gateway { port, host } => {
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
info!("POST http://{host}:{port}/webhook — send JSON messages");
info!("GET http://{host}:{port}/health — health check");
if port == 0 {
info!("🚀 Starting ZeroClaw Gateway on {host} (random port)");
} else {
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
}
gateway::run_gateway(&host, port, config).await
}