crates/gunnar-server/tests/wg_only_refuses_plain_ip.rs
//! **WireGuard-only (owner, 2026-10-04)**: every box-to-box connection of an
//! estate runs over the overlay, and a plain-IP connection to a box's utility
//! address is REFUSED. Under `--wg-only` the control plane (`:50051`) answers
//! only on the tunnel's address — its utility door is closed — and SSH, bound
//! for the public too, drops a connection that arrives at the utility address
//! before a byte of the protocol is spoken.
//!
//! No root and no `wg0` here: the utility address is `127.0.0.1` (where the
//! control plane is bound, as `eth1:50051` is on the appliance) and the tunnel
//! is `127.0.0.2` (both on `lo`), published through the same
//! [`gunnar_server::tunnel_addr`] a real `wg::establish` publishes through.
//!
//! ONE test function: the tunnel address is process-wide.
use std::io::Read as _;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_server::stores::ServerStores;
use gunnar_server::tunnel_addr;
fn refused(at: SocketAddr) -> bool {
matches!(TcpStream::connect_timeout(&at, Duration::from_millis(500)), Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused)
}
fn reachable(at: SocketAddr) -> bool {
TcpStream::connect_timeout(&at, Duration::from_millis(500)).is_ok()
}
/// The first bytes the server says (an SSH banner), or what came back instead.
fn banner(at: SocketAddr) -> Result<String, String> {
let mut c = TcpStream::connect_timeout(&at, Duration::from_millis(500))
.map_err(|e| format!("connect: {e}"))?;
c.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let mut buf = [0u8; 64];
match c.read(&mut buf) {
Ok(0) => Err("closed without a word".into()),
Ok(n) => Ok(String::from_utf8_lossy(&buf[..n]).into_owned()),
Err(e) => Err(format!("read: {e}")),
}
}
fn wait(what: &str, secs: u64, mut f: impl FnMut() -> bool) {
let t0 = Instant::now();
while !f() {
assert!(
t0.elapsed() < Duration::from_secs(secs),
"timed out waiting for: {what}"
);
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn under_wg_only_a_plain_ip_connection_to_a_box_is_refused_and_the_tunnel_answers() {
tunnel_addr::publish(None);
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("rt");
let config = gunnar_server::ServeConfig {
// SSH on every address (the public's git clients), the control plane on
// the "utility" one, as the appliance's medium binds them.
ssh_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
wg_only: true,
..gunnar_server::ServeConfig::ephemeral()
};
let server = rt
.block_on(gunnar_server::Server::start(Arc::clone(&stores), config))
.expect("the server starts under --wg-only");
let grpc = server.grpc_addr().expect("a control plane");
let ssh_port = server.ssh_addr().port();
let utility = Ipv4Addr::LOCALHOST;
let tunnel = Ipv4Addr::new(127, 0, 0, 2);
let at = |ip: Ipv4Addr, port: u16| SocketAddr::new(IpAddr::V4(ip), port);
// ── 1. No tunnel: the control plane's utility door is closed. ──
wait("the utility door to close", 10, || {
refused(at(utility, grpc.port()))
});
assert!(
refused(at(tunnel, grpc.port())),
"and nothing answers on the tunnel address, which is not up"
);
assert!(
banner(at(utility, ssh_port)).is_err(),
"SSH says nothing to a plain-IP connection at the utility address"
);
// ── 2. The tunnel comes up: the control plane answers there, and only there. ──
tunnel_addr::publish(Some(tunnel));
wait("the control plane to answer on the tunnel", 10, || {
reachable(at(tunnel, grpc.port()))
});
assert!(
refused(at(utility, grpc.port())),
"a plain-IP connection to the control plane is REFUSED"
);
let b = banner(at(tunnel, ssh_port)).expect("SSH answers on the tunnel");
assert!(b.starts_with("SSH-2.0-"), "{b:?}");
assert!(
banner(at(utility, ssh_port)).is_err(),
"and still says nothing at the utility address"
);
tunnel_addr::publish(None);
rt.block_on(server.shutdown()).ok();
}