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