gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **AIRGAP: the default gunnar-server, started with an empty data root, serves and dials
//! nothing** (owner rule 2026-10-25: default builds make zero outbound connections).
//!
//! The binary of this build is started the way an operator starts it (`serve` on loopback
//! ports, no config at all). It must reach its normal start (the banner names its listeners),
//! answer its health flows (the SSH banner on its git port, the gRPC port accepting), and in
//! the seconds it runs no socket of the process may have a peer outside this machine. The
//! release lane also runs this test in a container with `--network none`, where any outbound
//! attempt fails as "unreachable" in the log, so the log is searched for that too.
//!
//! The binary is built with this crate's test features (its dev-dependency on itself turns on
//! `wg-client` and `dhcp-client`), so the claim is the stronger one: even with the pairing and
//! WireGuard clients compiled in, an empty data root dials nothing.

use std::io::{BufRead as _, Read as _};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpStream};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Every remote address of the process's sockets that is not this machine or unset.
fn foreign_peers(pid: u32) -> Vec<String> {
    let mut out = Vec::new();
    for table in ["tcp", "tcp6", "udp", "udp6"] {
        let Ok(text) = std::fs::read_to_string(format!("/proc/{pid}/net/{table}")) else {
            continue;
        };
        for line in text.lines().skip(1) {
            let Some(remote) = line.split_whitespace().nth(2) else {
                continue;
            };
            let Some((addr, _port)) = remote.split_once(':') else {
                continue;
            };
            let ip = match addr.len() {
                8 => u32::from_str_radix(addr, 16)
                    .ok()
                    .map(|v| IpAddr::V4(Ipv4Addr::from(v.swap_bytes()))),
                32 => u128::from_str_radix(addr, 16).ok().map(|v| {
                    // /proc writes four little-endian 32-bit words.
                    let b = v.to_be_bytes();
                    let mut o = [0u8; 16];
                    for w in 0..4 {
                        for i in 0..4 {
                            o[w * 4 + i] = b[w * 4 + 3 - i];
                        }
                    }
                    IpAddr::V6(Ipv6Addr::from(o))
                }),
                _ => None,
            };
            let Some(ip) = ip else { continue };
            let local = ip.is_unspecified()
                || ip.is_loopback()
                || matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback() || v4.is_unspecified()));
            if !local {
                out.push(format!("{table} {ip}"));
            }
        }
    }
    out
}

#[test]
fn the_default_server_starts_serves_and_dials_nothing() {
    let data = tempfile::tempdir().expect("a data root");
    let home = tempfile::tempdir().expect("a home");
    let log_path = data.path().join("server.log");
    let log = std::fs::File::create(&log_path).expect("the log");
    let mut child = Command::new(env!("CARGO_BIN_EXE_gunnar-server"))
        .arg("--data-root")
        .arg(data.path().join("root"))
        .args([
            "serve",
            "--ssh-bind",
            "127.0.0.1:0",
            "--grpc-bind",
            "127.0.0.1:0",
        ])
        .env_clear()
        .env("HOME", home.path())
        .env("PATH", "/usr/bin:/bin")
        .env("RUST_LOG", "info")
        .stdout(Stdio::piped())
        .stderr(Stdio::from(log))
        .spawn()
        .expect("the server starts");
    let pid = child.id();
    let mut reader = std::io::BufReader::new(child.stdout.take().expect("piped"));
    let (mut ssh, mut grpc) = (None, None);
    let deadline = Instant::now() + Duration::from_secs(60);
    while Instant::now() < deadline && (ssh.is_none() || grpc.is_none()) {
        let mut line = String::new();
        if matches!(reader.read_line(&mut line), Ok(0) | Err(_)) {
            break;
        }
        let word = |p: &str| {
            line.strip_prefix(p)
                .and_then(|r| r.split_whitespace().next())
                .map(str::to_owned)
        };
        ssh = ssh.or_else(|| word("ssh "));
        grpc = grpc.or_else(|| word("grpc "));
    }
    std::thread::spawn(move || {
        let mut sink = String::new();
        let _ = reader.read_to_string(&mut sink);
    });
    let (ssh, grpc) = (
        ssh.expect("the banner names the ssh listener"),
        grpc.expect("the banner names the gRPC listener"),
    );

    // The health flows: the git port answers with an SSH banner, the gRPC port accepts.
    let mut s = TcpStream::connect(&ssh).expect("the ssh port accepts");
    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
    let mut banner = [0u8; 8];
    s.read_exact(&mut banner).expect("an ssh banner");
    assert_eq!(&banner, b"SSH-2.0-", "the git port speaks SSH");
    TcpStream::connect(&grpc).expect("the gRPC port accepts");

    // A few seconds of an idle default server: no socket with a foreign peer.
    let mut seen = Vec::new();
    for _ in 0..30 {
        seen.extend(foreign_peers(pid));
        std::thread::sleep(Duration::from_millis(100));
    }
    let _ = child.kill();
    let _ = child.wait();
    let text = std::fs::read_to_string(&log_path).unwrap_or_default();
    assert!(
        seen.is_empty(),
        "the default server opened a socket to another machine: {seen:?}\n{text}"
    );
    for tell in [
        "Network is unreachable",
        "network unreachable",
        "failed to lookup address",
        "dns error",
    ] {
        assert!(
            !text.contains(tell),
            "the log shows an outbound attempt ({tell}):\n{text}"
        );
    }
}