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