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

nordisk/gunnar

crates/gunnar-cli/tests/tunnel_address_ssh.rs

//! **A SEALED box takes its tunnel address over its SSH door** (2026-09-21, T17).
//!
//! The sealed installer runs `serve --no-grpc --admin-key @/data/admin.pub`:
//! no control plane at all, so neither `Bootstrap.AssignTunnelAddress` nor
//! `Admin.SetTunnelAddress` can reach it. This drives the real server in that
//! exact shape — `no_grpc: true`, the admin installed from `admin_key` — with
//! the appliance ceremony on, and hands the address over the way
//! `gunnar control tunnel enrol --ssh` does: `gunnar_client::ssh::connect_exec`
//! of `gunnar-tunnel-address <cidr>`, the answer parsed by
//! `gunnar_proto::parse_tunnel_answer`. The answer comes from
//! `appliance::tunnel_address_answer`, the function both gRPC doors call.
//!
//! One test in this binary: `GUNNAR_APPLIANCE=1` is process-wide.

use std::io::Read as _;
use std::path::Path;
use std::sync::Arc;

use gunnar_cli::server::{ServeConfig, Server, ServerStores};
use gunnar_core::ssh_key::PrivateKey;

fn mint(dir: &Path, name: &str) -> (PrivateKey, std::path::PathBuf) {
    let bytes = gunnar_core::mint_ed25519().expect("mint a key");
    let path = dir.join(name);
    std::fs::write(&path, &bytes).unwrap();
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
    }
    (PrivateKey::from_openssh(bytes).expect("parse"), path)
}

fn line(key: &PrivateKey) -> String {
    key.public_key()
        .to_openssh()
        .expect("an authorized_keys line")
}

/// `gunnar control tunnel --ssh`'s dial, with the host key accepted (a test
/// must not write the operator's `~/.ssh/known_hosts`).
fn over_ssh(port: u16, key: &Path, address: &str) -> String {
    let loc = gunnar_client::ssh::SshLocation {
        user: "git".into(),
        host: "127.0.0.1".into(),
        port,
        path: String::new(),
    };
    let opts = gunnar_client::SshOptions::new()
        .with_key(key)
        .with_host_key_policy(gunnar_client::HostKeyPolicy::AcceptAny);
    let command = if address.is_empty() {
        gunnar_ssh::TUNNEL_ADDRESS_VERB.to_string()
    } else {
        format!("{} {address}", gunnar_ssh::TUNNEL_ADDRESS_VERB)
    };
    let mut channel =
        gunnar_client::ssh::connect_exec(&loc, &command, &opts).expect("the ssh door answers");
    let mut out = String::new();
    channel
        .io()
        .0
        .read_to_string(&mut out)
        .expect("read the answer");
    out
}

#[test]
fn a_sealed_box_takes_its_tunnel_address_from_its_admin_over_ssh_and_from_nobody_else() {
    std::env::set_var(gunnar_cli::server::appliance::GATE_ENV, "1");
    std::env::remove_var(gunnar_cli::server::appliance::DATA_ENV);
    let dir = tempfile::tempdir().expect("a data root");
    let keys = tempfile::tempdir().expect("a key dir");
    let root = dir.path().to_path_buf();
    let ceremony =
        gunnar_cli::server::appliance::run(&root, &mut |_| {}).expect("first boot mints");
    let (admin, admin_path) = mint(keys.path(), "admin");
    let (bob, bob_path) = mint(keys.path(), "bob");

    let stores = Arc::new(ServerStores::open(&root).expect("open the stores"));
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("a runtime");
    // THE SEALED SHAPE: no control plane, the admin from the medium's key.
    let server = runtime
        .block_on(Server::start(
            Arc::clone(&stores),
            ServeConfig {
                no_grpc: true,
                admin_key: Some(line(&admin)),
                ..ServeConfig::ephemeral()
            },
        ))
        .expect("start sealed");
    assert!(
        server.grpc_addr().is_none(),
        "the sealed box runs no control plane"
    );
    let port = server.ssh_addr().port();
    {
        use gunnar_grpc::ControlPlane as _;
        let plane = gunnar_cli::server::CoreControlPlane::new(Arc::clone(&stores));
        plane
            .add_principal("bob", "bob", gunnar_proto::PrincipalKind::User)
            .expect("bob");
        plane
            .add_key("bob", &line(&bob), "", None, "", None)
            .expect("bob's key");
    }
    let address_file = gunnar_cli::server::appliance::wg_dir(&root)
        .join(gunnar_cli::server::appliance::ADDRESS_FILE);

    // bob: authenticated, no server grant — refused before the door.
    let said = over_ssh(port, &bob_path, "10.77.0.2/16");
    assert!(
        gunnar_proto::parse_tunnel_answer(&said).is_err() && said.contains("server-wide Admin"),
        "{said}"
    );
    assert!(!address_file.exists(), "nobody but an admin writes it");

    // The admin: written; the answer carries the key the box minted.
    let set = gunnar_proto::parse_tunnel_answer(&over_ssh(port, &admin_path, "10.77.0.2/16"))
        .expect("the admin's answer");
    assert!(set.changed);
    assert_eq!(set.address, "10.77.0.2/16");
    assert_eq!(set.wg_public_key, ceremony.public_b64);
    assert_eq!(
        std::fs::read_to_string(&address_file).unwrap().trim(),
        "10.77.0.2/16"
    );

    // Same address: no-op. Another: never re-pointed. No argument: a read.
    assert!(
        !gunnar_proto::parse_tunnel_answer(&over_ssh(port, &admin_path, "10.77.0.2/16"))
            .unwrap()
            .changed
    );
    let moved = over_ssh(port, &admin_path, "10.77.0.3/16");
    assert!(
        gunnar_proto::parse_tunnel_answer(&moved)
            .unwrap_err()
            .contains("never re-pointed"),
        "{moved}"
    );
    let read = gunnar_proto::parse_tunnel_answer(&over_ssh(port, &admin_path, "")).unwrap();
    assert_eq!(
        (read.address.as_str(), read.changed),
        ("10.77.0.2/16", false)
    );

    drop(server);
}