Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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();
}