crates/gunnar-front/tests/grpc_hole.rs
//! **The UI = CLI hole, end to end** (GRAND-PLAN-V2 §10, A6): the REAL gunnar
//! CLI (`gunnar_cli::run`, the binary's own dispatch, in its own process) runs
//! `gunnar control … --at https://localhost:<port>` against an in-process FRONT
//! (its :443, TLS with a fed certificate from a test CA) in front of a REAL
//! control plane (`gunnar_grpc::router` over real stores, on loopback, plain
//! h2c as gunnar's listener is). Every read and write a person makes goes
//! through it, each signed with the caller's OWN key and carried untouched:
//! the server says who called (an admin, an ordinary user, nobody), refuses
//! what that caller may not do, and the console's audit trail names each call
//! by the caller's own fingerprint. Past the limit a call is refused, and
//! HTTP/1 on the same :443 is still the face's.
//!
//! **And the hole keeps the face's rules** (RA1-1): it answers on the
//! console's own names only (never on a callback-only one), only to the
//! estate's own network unless the front is set `public`, and never carries
//! `Bootstrap.Enroll` or any other call that hands a fresh box to its first
//! caller: an UNENROLLED box behind the front cannot be claimed through any of
//! its names.
//!
//! `harness = false`: the test binary is ALSO the CLI. Started with
//! [`CLI_ENV`] set it is exactly `gunnar`'s `main` (the same `run`, the same
//! exit code and output), so each step is a real process whose stdout, stderr
//! and status are asserted. No network beyond loopback; the site is
//! `localhost`, the people are `admin` and `bob`.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use gunnar_front::web::hole::{Forwarded, Hole};
use gunnar_server::stores::ServerStores;
use gunnar_server::CoreControlPlane;
/// Set in the CLI's own process.
const CLI_ENV: &str = "GUNNAR_FRONT_TEST_AS_CLI";
fn main() {
if std::env::var_os(CLI_ENV).is_some() {
// `gunnar`'s main, verbatim (crates/gunnar-cli/src/main.rs).
let outcome = gunnar_cli::run();
gunnar_common::logqueue::flush(Duration::from_secs(5));
if let Err(err) = outcome {
eprintln!("gunnar: {err:#}");
std::process::exit(1);
}
return;
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a runtime");
let n = rt.block_on(scenario());
println!("grpc_hole: {n} checks passed");
}
/// What one CLI run printed, and how it ended.
struct Ran {
ok: bool,
out: String,
err: String,
}
impl std::fmt::Debug for Ran {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"ok={} stdout:\n{}\nstderr:\n{}",
self.ok, self.out, self.err
)
}
}
struct Site {
at: String,
home: PathBuf,
ca: PathBuf,
}
impl Site {
/// `gunnar control <args…> --at https://localhost:<port>`, as a process.
async fn gunnar(&self, args: &[&str]) -> Ran {
let mut cmd = Command::new(std::env::current_exe().expect("this binary"));
cmd.env(CLI_ENV, "1")
.env("HOME", &self.home)
.env("GUNNAR_SITE_CA", &self.ca)
.env("RUST_LOG", "warn")
.env_remove("GUNNAR_CLIENT_CERT")
.env_remove("GUNNAR_CLIENT_KEY")
.env_remove("GUNNAR_CA")
.env_remove("GUNNAR_CONTROL_ADDR")
.arg("control")
.args(args)
.arg("--at")
.arg(&self.at);
let out = tokio::task::spawn_blocking(move || cmd.output().expect("run the CLI"))
.await
.expect("joined");
Ran {
ok: out.status.success(),
out: String::from_utf8_lossy(&out.stdout).into_owned(),
err: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
}
fn mint(dir: &Path, name: &str) -> (PathBuf, String, String) {
let pem = gunnar_core::mint_ed25519().expect("mint");
let text = String::from_utf8(pem).expect("utf8");
let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&text).expect("parse");
let line = key.public_key().to_openssh().expect("line");
let fp = key
.public_key()
.fingerprint(gunnar_core::ssh_key::HashAlg::Sha256)
.to_string();
let path = dir.join(name);
std::fs::write(&path, &text).expect("write the key");
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("0600");
}
std::fs::write(dir.join(format!("{name}.pub")), format!("{line}\n")).expect("write the .pub");
(path, line, fp)
}
/// The front's second name: a payment vendor's webhook name (callback-only).
const PAY: &str = "pay.localhost";
/// A test CA and a leaf for `localhost` (and [`PAY`]) it signed: (CA PEM, chain PEM, key PEM).
fn site_certificate() -> (String, String, String) {
let ca_key = rcgen::KeyPair::generate().unwrap();
let mut p = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
p.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
p.distinguished_name
.push(rcgen::DnType::CommonName, "grpc hole test CA");
let ca_cert = p.self_signed(&ca_key).unwrap();
let ca = rcgen::Issuer::new(p, ca_key);
let leaf_key = rcgen::KeyPair::generate().unwrap();
let mut lp =
rcgen::CertificateParams::new(vec!["localhost".to_string(), PAY.to_string()]).unwrap();
lp.distinguished_name
.push(rcgen::DnType::CommonName, "localhost");
let now = time::OffsetDateTime::now_utc();
lp.not_before = now - time::Duration::days(1);
lp.not_after = now + time::Duration::days(80);
let leaf = lp.signed_by(&leaf_key, &ca).unwrap();
(
ca_cert.pem(),
format!("{}{}", leaf.pem(), ca_cert.pem()),
leaf_key.serialize_pem(),
)
}
fn free_addr() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind")
.local_addr()
.expect("addr")
}
/// The control plane's services, as gunnar-proto declares them.
fn proto_services() -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../gunnar-proto/proto")];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).expect("the proto dir").flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "proto") {
let text = std::fs::read_to_string(&p).unwrap();
let package = text
.lines()
.find_map(|l| {
l.trim()
.strip_prefix("package ")
.map(|s| s.trim_end_matches(';').trim().to_string())
})
.unwrap_or_default();
for l in text.lines() {
if let Some(rest) = l.strip_prefix("service ") {
out.push(format!(
"{package}.{}",
rest.split_whitespace().next().unwrap()
));
}
}
}
}
}
out.sort();
out
}
/// An HTTP/1.1 GET on the front's :443 (no ALPN h2).
async fn http1_get(addr: SocketAddr, ca_pem: &str, path: &str) -> String {
use rustls::pki_types::pem::PemObject as _;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut roots = rustls::RootCertStore::empty();
for c in rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_bytes()) {
roots.add(c.unwrap()).unwrap();
}
let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let sni = rustls::pki_types::ServerName::try_from("localhost".to_string()).unwrap();
let mut tls = tokio_rustls::TlsConnector::from(Arc::new(cfg))
.connect(sni, tcp)
.await
.unwrap();
tls.write_all(format!("POST {path} HTTP/1.1\r\nHost: localhost\r\ncontent-type: application/grpc\r\ncontent-length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
let mut out = String::new();
let _ = tls.read_to_string(&mut out).await;
out
}
/// What one raw gRPC call through the front got: its status and message.
#[derive(Debug)]
struct Raw {
status: Option<u32>,
message: String,
}
/// **One raw gRPC call on the front's :443** over HTTP/2 (TLS for `localhost`):
/// `path` with an empty message, asked for `authority` (the name the front
/// reads), with `x-forwarded-for` when given (believed from loopback, the way
/// the console reads a client address). No credential.
async fn raw_grpc(
addr: SocketAddr,
ca_pem: &str,
authority: &str,
path: &str,
xff: Option<&str>,
) -> Raw {
use http_body_util::BodyExt as _;
use rustls::pki_types::pem::PemObject as _;
let mut roots = rustls::RootCertStore::empty();
for c in rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_bytes()) {
roots.add(c.unwrap()).unwrap();
}
let mut cfg = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
cfg.alpn_protocols = vec![b"h2".to_vec()];
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let sni = rustls::pki_types::ServerName::try_from("localhost".to_string()).unwrap();
let tls = tokio_rustls::TlsConnector::from(Arc::new(cfg))
.connect(sni, tcp)
.await
.unwrap();
let (mut send, conn) = hyper::client::conn::http2::handshake(
hyper_util::rt::TokioExecutor::new(),
hyper_util::rt::TokioIo::new(tls),
)
.await
.expect("h2");
tokio::spawn(async move {
let _ = conn.await;
});
let mut b = http::Request::builder()
.method("POST")
.uri(format!("https://{authority}:{}{path}", addr.port()))
.header("content-type", "application/grpc")
.header("te", "trailers");
if let Some(x) = xff {
b = b.header("x-forwarded-for", x);
}
let req = b
.body(http_body_util::Full::new(bytes::Bytes::from_static(&[
0, 0, 0, 0, 0,
])))
.unwrap();
let resp = send.send_request(req).await.expect("an answer");
let head = resp.headers().clone();
let body = resp.into_body().collect().await.ok();
let trailers = body.and_then(|b| b.trailers().cloned());
let pick = |name: &str| {
head.get(name)
.or_else(|| trailers.as_ref().and_then(|t| t.get(name)))
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
Raw {
status: pick("grpc-status").and_then(|s| s.parse().ok()),
message: pick("grpc-message").unwrap_or_default(),
}
}
async fn scenario() -> usize {
let mut checks = 0;
let check = |checks: &mut usize, ok: bool, what: &str| {
assert!(ok, "{what}");
*checks += 1;
println!("ok {what}");
};
// The allow-list is the control plane's own services, no more, no fewer.
let mut listed: Vec<String> = gunnar_front::web::hole::SERVICES
.iter()
.map(|s| s.to_string())
.collect();
listed.sort();
check(
&mut checks,
listed == proto_services(),
"the hole's services are gunnar-proto's services",
);
let dir = tempfile::tempdir().expect("tempdir");
let keys = dir.path().join("keys");
std::fs::create_dir_all(&keys).unwrap();
let (admin_key, admin_line, admin_fp) = mint(&keys, "admin");
let (bob_key, _bob_line, bob_fp) = mint(&keys, "bob");
let (_, front_line, front_fp) = mint(&keys, "front-console");
// ── a REAL control plane: the admin enrolled, and the front's own console key
// an admin too (as on an estate): a call carried as the front's would pass.
let stores = Arc::new(ServerStores::open(dir.path().join("gunnar")).expect("stores"));
stores
.auth()
.bootstrap_admin(&admin_line)
.expect("the first admin");
stores
.auth()
.register_key(gunnar_core::BOOTSTRAP_ADMIN_ID, &front_line, None)
.expect("the front's console key, an admin's");
let control = free_addr();
let ctx = gunnar_grpc::ServiceContext::new(
Arc::new(CoreControlPlane::new(Arc::clone(&stores))),
Arc::new(gunnar_grpc::EventBus::new(16)),
gunnar_grpc::ServerConfig::default(),
);
tokio::spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0).serve(control).await;
});
let t0 = Instant::now();
while std::net::TcpStream::connect(control).is_err() {
assert!(t0.elapsed() < Duration::from_secs(10), "gunnar listens");
tokio::time::sleep(Duration::from_millis(25)).await;
}
// ── the FRONT: its :443 with a fed certificate for `localhost`, a face, the hole.
let (ca_pem, chain, key) = site_certificate();
let ca = dir.path().join("site-ca.pem");
std::fs::write(&ca, &ca_pem).unwrap();
let feed = BTreeMap::from([
(
gunnar_front::web::FEED_WEB_HOSTS.to_string(),
format!("localhost,{PAY}"),
),
(
gunnar_front::web::FEED_WEB_CALLBACK_ONLY.to_string(),
PAY.to_string(),
),
(gunnar_front::web::certs::fed_cert_key("localhost"), chain),
(gunnar_front::web::certs::fed_key_key("localhost"), key),
]);
let front_dir = dir.path().join("front");
std::fs::create_dir_all(&front_dir).unwrap();
let l80 = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let l443 = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let a443 = l443.local_addr().unwrap();
let web = gunnar_front::web::start_on_with(
&front_dir,
"localhost".into(),
Arc::new(move || feed.clone()),
l80,
l443,
false,
);
web.app
.set(axum::Router::new().fallback(|| async { "the console\n" }));
let hole: Hole = web.app.hole();
let audit: Arc<Mutex<Vec<Forwarded>>> = Arc::new(Mutex::new(Vec::new()));
let a2 = Arc::clone(&audit);
hole.set_audit(Arc::new(move |f: &Forwarded| {
a2.lock().unwrap().push(f.clone())
}));
hole.retarget(Some(control.to_string()));
let t0 = Instant::now();
while !web.resolver.any() {
assert!(
t0.elapsed() < Duration::from_secs(20),
"the fed certificate is served"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
let home = dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let site = Site {
at: format!("https://localhost:{}", a443.port()),
home,
ca,
};
let admin = admin_key.to_str().unwrap();
let bob = bob_key.to_str().unwrap();
let bob_pub = format!("@{}", keys.join("bob.pub").display());
// ── reads and writes, each as its own caller, through the front.
let r = site.gunnar(&["status"]).await;
check(
&mut checks,
r.ok && r.out.contains("principals"),
&format!("`control status` (no credential) answers through the front: {r:?}"),
);
let r = site.gunnar(&["login", "-i", admin]).await;
check(
&mut checks,
r.ok && r.out.contains("gunnar-admin"),
&format!("the admin logs in through the front and is the admin: {r:?}"),
);
let r = site
.gunnar(&["user", "add", "bob", "--key", &bob_pub, "-i", admin])
.await;
check(
&mut checks,
r.ok,
&format!("the admin adds bob through the front (a write): {r:?}"),
);
let r = site.gunnar(&["login", "-i", bob]).await;
check(
&mut checks,
r.ok && r.out.contains("ordinary user"),
&format!("bob logs in through the front as bob, not as the front's admin key: {r:?}"),
);
let r = site.gunnar(&["repo", "create", "bob/one", "-i", bob]).await;
check(
&mut checks,
r.ok,
&format!("bob creates bob/one through the front (a write): {r:?}"),
);
let r = site
.gunnar(&["repo", "create", "admin/not-bobs", "-i", bob])
.await;
check(&mut checks, !r.ok, &format!("bob may not create in the admin's account: the server refuses bob, carried as bob: {r:?}"));
let r = site.gunnar(&["repo", "list", "-i", bob]).await;
check(
&mut checks,
r.ok && r.out.contains("bob/one") && !r.out.contains("not-bobs"),
&format!("bob lists repositories through the front (a read): {r:?}"),
);
let r = site.gunnar(&["repo", "get", "bob/one", "-i", bob]).await;
check(
&mut checks,
r.ok && r.out.contains("bob/one"),
&format!("bob reads bob/one through the front: {r:?}"),
);
let r = site.gunnar(&["user", "list", "-i", admin]).await;
check(
&mut checks,
r.ok && r.out.contains("bob"),
&format!("the admin lists users through the front: {r:?}"),
);
// ── the audit trail: every call, named by the caller's own key, never the front's.
let lines = audit.lock().unwrap().clone();
check(
&mut checks,
lines.iter().all(|f| f.answered_by == "main"),
"every call so far was answered by the main",
);
check(
&mut checks,
lines
.iter()
.any(|f| f.caller == bob_fp && f.method == "Repos.Create" && f.status == Some(0)),
&format!("bob's create is audited as bob, OK: {lines:?}"),
);
check(
&mut checks,
lines.iter().any(|f| {
f.caller == bob_fp && f.method == "Repos.Create" && f.status.is_some_and(|s| s != 0)
}),
"bob's refused create is audited as bob, with the server's refusal",
);
check(
&mut checks,
lines.iter().any(|f| f.caller == admin_fp),
"the admin's calls are audited as the admin",
);
check(
&mut checks,
lines.iter().all(|f| f.caller != front_fp),
"no call is the front's",
);
check(
&mut checks,
lines
.iter()
.any(|f| f.caller == "anonymous" && f.method.starts_with("Bootstrap.")),
"the uncredentialled status call is audited as anonymous",
);
// ── HTTP/1 on the same :443 is the face's, even with a gRPC content type.
let page = http1_get(a443, &ca_pem, "/gunnar.v1.Repos/List").await;
check(
&mut checks,
page.starts_with("HTTP/1.1 200") && page.contains("the console"),
&format!("HTTP/1 is untouched: {page}"),
);
// ── the limit: past it the CLI is refused, RESOURCE_EXHAUSTED, and audited.
hole.set_per_minute(1);
let first = site.gunnar(&["repo", "list", "-i", bob]).await;
let second = site.gunnar(&["repo", "list", "-i", bob]).await;
check(
&mut checks,
first.ok,
&format!("within the limit the call passes: {first:?}"),
);
let said = second.err.to_ascii_lowercase();
check(
&mut checks,
!second.ok && (said.contains("too many calls") || said.contains("resource")),
&format!("past the limit the CLI is refused: {second:?}"),
);
check(
&mut checks,
audit
.lock()
.unwrap()
.iter()
.any(|f| f.answered_by == "rate-limited" && f.status == Some(8)),
"the refusal is audited as rate-limited",
);
hole.set_per_minute(gunnar_front::web::hole::PER_CLIENT_PER_MINUTE);
// ── RA1-1: the hole keeps the face's rules ─────────────────────────────
// The callback-only name and a name the front does not serve carry nothing.
for host in [PAY, "127.0.0.1"] {
let r = raw_grpc(a443, &ca_pem, host, "/gunnar.v1.Repos/List", None).await;
check(
&mut checks,
r.status == Some(7) && r.message.contains("front-hole-name"),
&format!("{host} carries no control plane: {r:?}"),
);
}
check(
&mut checks,
audit
.lock()
.unwrap()
.iter()
.any(|f| f.answered_by == "refused-name"),
"the refusal is audited as refused-name",
);
// Own-network, the default: a public client (as the console reads it) is
// refused at the front; `public` carries it to the main.
check(
&mut checks,
hole.reach() == gunnar_front::web::hole::Reach::OwnNetwork,
"the hole is own-network by default",
);
let r = raw_grpc(
a443,
&ca_pem,
"localhost",
"/gunnar.v1.Repos/List",
Some("203.0.113.7"),
)
.await;
check(
&mut checks,
r.status == Some(7) && r.message.contains("front-hole-network"),
&format!("a public client is refused by default: {r:?}"),
);
let r = raw_grpc(
a443,
&ca_pem,
"localhost",
"/gunnar.v1.Repos/List",
Some("10.13.8.20"),
)
.await;
check(
&mut checks,
r.status != Some(7),
&format!("a client on the estate's own network is carried: {r:?}"),
);
hole.set_reach(gunnar_front::web::hole::Reach::Public);
let n = audit.lock().unwrap().len();
let r = raw_grpc(
a443,
&ca_pem,
"localhost",
"/gunnar.v1.Repos/List",
Some("203.0.113.7"),
)
.await;
check(
&mut checks,
r.status != Some(7),
&format!("set `public`, a public client is carried: {r:?}"),
);
check(
&mut checks,
audit.lock().unwrap()[n..]
.iter()
.any(|f| f.answered_by == "main" && f.client == "203.0.113.7"),
"and the main answered it",
);
// An UNENROLLED box behind the front: nobody can claim it through any of its names.
let fresh_stores =
Arc::new(ServerStores::open(dir.path().join("fresh")).expect("fresh stores"));
let fresh = free_addr();
let ctx = gunnar_grpc::ServiceContext::new(
Arc::new(CoreControlPlane::new(Arc::clone(&fresh_stores))),
Arc::new(gunnar_grpc::EventBus::new(16)),
gunnar_grpc::ServerConfig::default(),
);
tokio::spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0).serve(fresh).await;
});
let t0 = Instant::now();
while std::net::TcpStream::connect(fresh).is_err() {
assert!(
t0.elapsed() < Duration::from_secs(10),
"the fresh box listens"
);
tokio::time::sleep(Duration::from_millis(25)).await;
}
hole.retarget(Some(fresh.to_string()));
let r = site.gunnar(&["status"]).await;
check(&mut checks, r.ok && r.out.contains("bootstrap OPEN"), &format!("the fresh box's window is open, and `control status` reads it through the front: {r:?}"));
let r = site
.gunnar(&["bootstrap", "mallory", "--key", &bob_pub])
.await;
check(
&mut checks,
!r.ok && r.err.contains("front-hole-method"),
&format!("`control bootstrap` through the front is refused at the front, by name: {r:?}"),
);
for host in ["localhost", PAY, "127.0.0.1"] {
for from in [None, Some("203.0.113.7")] {
for method in [
"Enroll",
"BecomeTwin",
"Restore",
"AssignTunnelAddress",
"TwinCsr",
"InstallTwinCertificate",
] {
let r = raw_grpc(
a443,
&ca_pem,
host,
&format!("/gunnar.v1.Bootstrap/{method}"),
from,
)
.await;
check(
&mut checks,
r.status == Some(7),
&format!(
"Bootstrap.{method} on {host} from {from:?} is refused at the front: {r:?}"
),
);
}
}
}
check(
&mut checks,
audit
.lock()
.unwrap()
.iter()
.filter(|f| f.method.starts_with("Bootstrap.") && f.method != "Bootstrap.Status")
.all(|f| f.answered_by != "main"),
"no claiming call reached the box",
);
let r = site.gunnar(&["status"]).await;
check(
&mut checks,
r.ok && r.out.contains("bootstrap OPEN") && r.out.contains("principals 0"),
&format!("after every try the box is still unclaimed: {r:?}"),
);
checks
}