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_admin.rs

//! **An enrolled box with no tunnel address has an admin door, and only an
//! admin door** (2026-09-21, T17).
//!
//! `Bootstrap.AssignTunnelAddress` writes only while the box is fresh. A box
//! installed from a medium that carried no `[Interface] Address`, and enrolled
//! before anybody handed it one, had no way to get an address at all: the
//! unauthenticated door is closed for good once an identity exists. So
//! `Admin.SetTunnelAddress` exists, held to server-wide Admin, with the same
//! semantics (the same address again is a no-op, another one is refused).
//!
//! Driven over a real listener, against a real enrolled data root, with the
//! appliance ceremony on (`GUNNAR_APPLIANCE=1` — set once, in this test
//! binary's own process, which holds only this one test).

use std::sync::Arc;

use gunnar_cli::server::{ServeConfig, Server, ServerStores};
use gunnar_core::ssh_key::PrivateKey;
use gunnar_proto::admin_client::AdminClient;
use gunnar_proto::bootstrap_client::BootstrapClient;
use gunnar_proto::{AssignTunnelAddressRequest, EnrollRequest};

fn mint() -> PrivateKey {
    PrivateKey::from_openssh(gunnar_core::mint_ed25519().expect("mint a key"))
        .expect("parse it back")
}

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

#[test]
fn an_enrolled_box_takes_its_tunnel_address_from_an_admin_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 root = dir.path().to_path_buf();

    // The ceremony `serve` runs before the control plane listens: the key the
    // address is for.
    let ceremony =
        gunnar_cli::server::appliance::run(&root, &mut |_| {}).expect("first boot mints");

    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");
    let server = runtime
        .block_on(Server::start(
            Arc::clone(&stores),
            ServeConfig {
                no_grpc: false,
                ..ServeConfig::ephemeral()
            },
        ))
        .expect("start");
    // The signing channel the CLI itself uses: `authed` leaves the key in the
    // request and `CredentialSigner` signs it over the method name.
    let _in_runtime = runtime.enter();
    let channel = gunnar_grpc::client_signer::plain_channel(
        &server
            .grpc_addr()
            .expect("the control plane bound")
            .to_string(),
    )
    .expect("a loopback channel");
    let mut bootstrap = BootstrapClient::new(channel.clone());
    let mut admin = AdminClient::new(channel.clone());
    let req = |a: &str| AssignTunnelAddressRequest {
        address: a.to_string(),
    };

    // Enrol alice (gunnar-admin), then bob with a key and NO grant.
    let alice = mint();
    runtime
        .block_on(bootstrap.enroll(EnrollRequest {
            name: "alice".into(),
            openssh_public_key: line(&alice),
        }))
        .expect("the first enrol");
    let bob = mint();
    {
        // Through the running server's own Access verbs, as alice: the plane
        // that authenticates bob is the one that must learn his key.
        let mut access = gunnar_proto::access_client::AccessClient::new(channel.clone());
        runtime
            .block_on(access.add_principal(gunnar_grpc::client_signer::authed(
                &alice,
                gunnar_proto::AddPrincipalRequest {
                    name: "bob".into(),
                    kind: gunnar_proto::PrincipalKind::User as i32,
                    id: "bob".into(),
                },
            )))
            .expect("bob");
        runtime
            .block_on(access.add_key(gunnar_grpc::client_signer::authed(
                &alice,
                gunnar_proto::AddKeyRequest {
                    principal_id: "bob".into(),
                    openssh_public_key: line(&bob),
                    ..Default::default()
                },
            )))
            .expect("bob's key");
    }

    // 1. The bootstrap door stays CLOSED for a write on an enrolled box.
    let shut = runtime
        .block_on(bootstrap.assign_tunnel_address(req("10.99.0.2/24")))
        .expect_err("the unauthenticated door must not write on an enrolled box");
    assert_eq!(shut.code(), tonic::Code::FailedPrecondition, "{shut:?}");
    assert!(
        shut.message().contains("Admin.SetTunnelAddress"),
        "the refusal names the door that works: {shut:?}"
    );
    let address_file = gunnar_cli::server::appliance::wg_dir(&root)
        .join(gunnar_cli::server::appliance::ADDRESS_FILE);
    assert!(!address_file.exists(), "a refusal writes nothing");

    // 2. No credential: refused before the plane is reached.
    let anon = runtime
        .block_on(admin.set_tunnel_address(req("10.99.0.2/24")))
        .expect_err("no credential, no address");
    assert_eq!(anon.code(), tonic::Code::Unauthenticated, "{anon:?}");

    // 3. An authenticated principal with no server grant: refused.
    let denied = runtime
        .block_on(admin.set_tunnel_address(gunnar_grpc::client_signer::authed(
            &bob,
            req("10.99.0.2/24"),
        )))
        .expect_err("bob holds no server Admin");
    assert_eq!(denied.code(), tonic::Code::PermissionDenied, "{denied:?}");
    assert!(!address_file.exists(), "nobody but an admin writes it");

    // 4. The admin: written, and the answer carries the key the hub registers.
    let set = runtime
        .block_on(admin.set_tunnel_address(gunnar_grpc::client_signer::authed(
            &alice,
            req("10.99.0.2/24"),
        )))
        .expect("server Admin sets the address of an enrolled box")
        .into_inner();
    assert!(set.changed);
    assert_eq!(set.address, "10.99.0.2/24");
    assert_eq!(
        set.wg_public_key, ceremony.public_b64,
        "the key the box minted, not another"
    );
    assert_eq!(
        std::fs::read_to_string(&address_file).unwrap().trim(),
        "10.99.0.2/24"
    );

    // 5. Same address again: a no-op. A different one: never re-pointed.
    let again = runtime
        .block_on(admin.set_tunnel_address(gunnar_grpc::client_signer::authed(
            &alice,
            req("10.99.0.2/24"),
        )))
        .expect("idempotent")
        .into_inner();
    assert!(!again.changed);
    let moved = runtime
        .block_on(admin.set_tunnel_address(gunnar_grpc::client_signer::authed(
            &alice,
            req("10.99.0.3/24"),
        )))
        .expect_err("a tunnel address is never re-pointed, not even by an admin");
    assert_eq!(moved.code(), tonic::Code::FailedPrecondition, "{moved:?}");

    // 6. The unauthenticated READ still answers (public key + address).
    let read = runtime
        .block_on(bootstrap.assign_tunnel_address(req("")))
        .expect("a read")
        .into_inner();
    assert_eq!(
        (read.address.as_str(), read.changed),
        ("10.99.0.2/24", false)
    );

    drop(_in_runtime);
    drop(server);
}