crates/gunnar-front/src/plugin_door/tests.rs
//! **The operator door, driven end to end on loopback**: the real door (mTLS
//! against a real installer CA).
use super::*;
use std::collections::BTreeMap;
use std::time::Duration as D;
const H: D = D::from_secs(3600);
struct Estate {
ca: mtls::InstallerCa,
operator: Issued,
door_identity: Issued,
}
fn estate() -> Estate {
let ca = mtls::InstallerCa::mint("gunnar.test", H).unwrap();
let operator = ca.issue_installer(H).unwrap();
let door_identity = ca.issue_bootstrap(H).unwrap();
Estate {
ca,
operator,
door_identity,
}
}
async fn door(root: &std::path::Path, e: &Estate) -> SocketAddr {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let identity = e.door_identity.clone();
let ctx = Arc::new(Ctx {
root: root.to_path_buf(),
now: Arc::new(|| (BTreeMap::new(), None)),
rotate: Arc::new(|_, _| Err("no feed in this test".into())),
});
tokio::spawn(serve(
l,
e.ca.cert_pem.clone(),
Arc::new(move || identity.clone()),
ctx,
));
addr
}
/// **A certificate that is not the operator's is refused by name** — a box's
/// own certificate and the medium's bootstrap certificate are from the SAME
/// CA; a certificate from another CA does not even complete the handshake.
/// Nothing is written.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_operator_certificate_is_refused_and_nothing_is_written() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
let (csr, key) = mtls::box_request().unwrap();
let box_cert = Issued {
cert_pem: e.ca.sign_box(&csr, "vm2", H).unwrap(),
key_pem: key,
};
for (who, cert) in [
("a box", box_cert),
("the bootstrap certificate", e.door_identity.clone()),
] {
let (status, a) = handover_remote(addr, &e.ca.cert_pem, &cert).await.unwrap();
assert_eq!(status, 403, "{who}: {a:?}");
assert_eq!(a["outcome"], format!("REFUSED [{NOT_OPERATOR}]"), "{who}");
}
let other = mtls::InstallerCa::mint("elsewhere.test", H).unwrap();
let stranger = other.issue_installer(H).unwrap();
assert!(
handover_remote(addr, &e.ca.cert_pem, &stranger)
.await
.is_err(),
"another CA's `installer` certificate is refused at the handshake"
);
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"nothing was written"
);
}
/// **The hand-over is read by the operator, and by nobody else**: the
/// certificate the front keeps NOW (not only the one it held when the
/// installer closed), with its key, as the successor's feed keys.
#[tokio::test(flavor = "multi_thread")]
async fn the_operator_reads_the_certificate_the_front_serves_now_and_no_one_else_does() {
use crate::web::certs::{self, Cert};
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
let (status, v) = handover_remote(addr, &e.ca.cert_pem, &e.operator)
.await
.unwrap();
assert_eq!(
(status, v["hosts"].as_array().map(Vec::len)),
(200, Some(0)),
"nothing kept yet: {v}"
);
// An ACME order lands after the close: kept on the data disk.
let (c, k) = certs::self_signed(&["gunnar.rs", "www.gunnar.rs", "api.gunnar.rs"], 80);
certs::keep(
dir.path(),
"gunnar.rs",
&Cert::parse("gunnar.rs", &c, &k, std::time::SystemTime::now()).unwrap(),
)
.unwrap();
let (status, v) = handover_remote(addr, &e.ca.cert_pem, &e.operator)
.await
.unwrap();
assert_eq!(status, 200);
assert_eq!(v["hosts"][0].as_str(), Some("gunnar.rs"));
assert_eq!(v["feed"]["tls.gunnar.rs.cert"].as_str(), Some(c.as_str()));
assert_eq!(v["feed"]["tls.gunnar.rs.key"].as_str(), Some(k.as_str()));
let (status, v) = handover_remote(addr, &e.ca.cert_pem, &e.door_identity)
.await
.unwrap();
assert_eq!(
status, 403,
"the bootstrap certificate is not the operator: {v}"
);
assert!(
v.get("feed").is_none() && !v.to_string().contains("PRIVATE"),
"no key to a stranger: {v}"
);
}
/// One raw request to the door with `client`'s certificate: (status, body).
async fn raw(
addr: SocketAddr,
ca_pem: &str,
client: &Issued,
method: &str,
path: &str,
body: &str,
) -> (u16, String) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let cfg = mtls::client_config(ca_pem, client).unwrap();
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let mut tls = tokio_rustls::TlsConnector::from(cfg)
.connect(mtls::vm_name(), tcp)
.await
.unwrap();
let head = format!("{method} {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", mtls::VM_SERVER_NAME, body.len());
tls.write_all(head.as_bytes()).await.unwrap();
tls.write_all(body.as_bytes()).await.unwrap();
let mut out = Vec::new();
let _ = tls.read_to_end(&mut out).await;
let text = String::from_utf8_lossy(&out).to_string();
let status = text
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
(
status,
text.split_once("\r\n\r\n")
.map(|(_, b)| b.to_string())
.unwrap_or_default(),
)
}
/// **ONE monetize: the door feeds no monetize** (owner 2026-10-10 13:35Z). The front runs
/// none, so a plugin, a vendor, a cloud or signup guest, a product's self-registration and
/// an estate's attach enrolment are NOT served here, not even to the operator: they are the
/// ONE monetize's (`monetize.ignalina.dk`). The operator's own verbs stay.
#[tokio::test(flavor = "multi_thread")]
async fn the_door_feeds_no_monetize_the_one_monetize_takes_plugins_vendors_and_estates() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
for path in [
"/plugin/gunnar",
"/vendor/stripe",
"/cloud/upcloud",
"/signup/gunnar",
"/register/gunnar",
"/attach/token",
"/attach/enrol",
] {
let (st, b) = raw(addr, &e.ca.cert_pem, &e.operator, "POST", path, "{}").await;
assert_eq!(
st, 404,
"{path} is the ONE monetize's, not this front's: {b}"
);
}
let (st, _) = raw(
addr,
&e.ca.cert_pem,
&e.operator,
"GET",
"/register/gunnar",
"",
)
.await;
assert_eq!(st, 404, "no self-registration is read here either");
assert!(
!dir.path().join("monetize").exists(),
"nothing of a monetize is written on the front"
);
let (st, b) = raw(addr, &e.ca.cert_pem, &e.operator, "GET", "/handover", "").await;
assert_eq!(st, 200, "the operator's own verbs stay: {b}");
}
/// **A fed credential is rotated through the door, by the operator only**
/// (h-admin-token): the front's kept feed holds the new value, sealed, and the
/// answer never carries it; a non-rotatable key and a weak bearer are refused.
#[tokio::test(flavor = "multi_thread")]
async fn the_operator_rotates_a_fed_credential_in_place_and_nobody_else_can() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let identity = e.door_identity.clone();
let rotated: Arc<std::sync::Mutex<Vec<(String, String)>>> = Arc::default();
let r2 = Arc::clone(&rotated);
let ctx = Arc::new(Ctx {
root: dir.path().to_path_buf(),
now: Arc::new(|| (BTreeMap::new(), None)),
rotate: Arc::new(move |k: &str, v: &str| {
r2.lock().unwrap().push((k.into(), v.into()));
Ok(true)
}),
});
tokio::spawn(serve(
l,
e.ca.cert_pem.clone(),
Arc::new(move || identity.clone()),
ctx,
));
let token = "qWJ8Zr3kP0sT7uV1xY4bN6mC9dF2gH5jL8aQ1wE4rT7yU0iO3pS6dF9gH2jK5lZ8".to_string();
let body = serde_json::json!({ "value": token }).to_string();
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.operator,
"POST",
"/feed/console_admin_token",
&body,
)
.await;
assert_eq!(st, 200, "{b}");
assert!(
b.contains("ROTATED") && !b.contains(&token),
"the answer names the key, never the value: {b}"
);
assert_eq!(
*rotated.lock().unwrap(),
vec![("console_admin_token".to_string(), token.clone())]
);
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.door_identity,
"POST",
"/feed/console_admin_token",
&body,
)
.await;
assert_eq!(st, 403, "not the operator: {b}");
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.operator,
"POST",
"/feed/tls.gunnar.rs.key",
&body,
)
.await;
assert!(st == 400 && b.contains("[feed-not-rotatable]"), "{st} {b}");
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.operator,
"POST",
"/feed/console_admin_token",
r#"{"value":"hunter2"}"#,
)
.await;
assert!(
st == 400 && b.contains("[console-admin-token-weak]"),
"{st} {b}"
);
assert_eq!(
rotated.lock().unwrap().len(),
1,
"nothing refused reached the feed"
);
}
/// **The off-site copy's stream is the operator's only**, and with no primary fed
/// it says so by name instead of streaming nothing (h-forge-offsite).
#[tokio::test(flavor = "multi_thread")]
async fn the_backup_stream_is_the_operators_only() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.door_identity,
"GET",
"/backup/stream",
"",
)
.await;
assert_eq!(st, 403, "{b}");
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.operator,
"GET",
"/backup/stream",
"",
)
.await;
assert!(st == 503 && b.contains("[backup-no-primary]"), "{st} {b}");
}
/// **The twin's health is the operator's only** (2026-10-05): the door's twin
/// lag and load answer the installer's certificate and nobody else, and with no primary fed it
/// says so by name.
#[tokio::test(flavor = "multi_thread")]
async fn the_twin_health_is_the_operators_only_and_names_a_missing_primary() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
let (st, b) = raw(
addr,
&e.ca.cert_pem,
&e.door_identity,
"GET",
"/twin/health",
"",
)
.await;
assert_eq!(st, 403, "{b}");
let (st, b) = raw(addr, &e.ca.cert_pem, &e.operator, "GET", "/twin/health", "").await;
assert!(
st == 503 && b.contains("[twin-health-no-primary]"),
"{st} {b}"
);
}
/// ★ **No shop reads a sold estate's twin through its door** (F4 under ONE monetize, G12):
/// the shop's old health-reader certificate (CN `twin-health-reader`, minted at the install
/// before 2026-10-10) is refused `GET /twin/health` like any other non-operator — monetize
/// reads the twin off the boxes' own Fleet reports and never calls INTO an estate.
#[tokio::test(flavor = "multi_thread")]
async fn a_legacy_health_reader_is_refused_the_twins_health_like_any_non_operator() {
let dir = tempfile::tempdir().unwrap();
let e = estate();
let addr = door(dir.path(), &e).await;
let (csr, key_pem) = mtls::box_request().unwrap();
let reader = Issued {
cert_pem: e.ca.sign_box(&csr, "twin-health-reader", H).unwrap(),
key_pem,
};
for (method, path, body) in [
("GET", "/twin/health", ""),
("GET", "/backup/stream", ""),
("GET", "/handover", ""),
("POST", "/feed/console_admin_token", r#"{"value":"x"}"#),
] {
let (st, b) = raw(addr, &e.ca.cert_pem, &reader, method, path, body).await;
assert_eq!(st, 403, "{method} {path}: {b}");
assert!(
b.contains("plugin-door-not-operator"),
"{method} {path}: {b}"
);
}
}
/// o-ops mock 2026-09-29: main's `/register/gunnar` got no HTTP answer and the front
/// said NOTHING — a handshake timeout returned silently and a failed serve was `let _`.
#[test]
fn a_connection_the_door_does_not_answer_is_always_said() {
let peer: SocketAddr = "10.13.12.118:40000".parse().unwrap();
let t = unanswered(peer, &Unanswered::HandshakeTimeout);
assert!(
t.contains("10.13.12.118:40000") && t.contains("handshake") && t.contains("10 s"),
"{t}"
);
let r = unanswered(
peer,
&Unanswered::Refused("invalid peer certificate: UnknownIssuer".into()),
);
assert!(r.contains("refused") && r.contains("UnknownIssuer"), "{r}");
let s = unanswered(peer, &Unanswered::Serve("connection reset".into()));
assert!(
s.contains("10.13.12.118:40000") && s.contains("connection reset"),
"{s}"
);
}
// ── the operator side's reading of the door's answer (stinky row 16, 2026-10-10) ──
//
// It was `let _ = read_to_end` — no bound, no deadline, the error dropped — and
// the body was found by `find('{')`: a cut answer was read as an answer, a
// silent door hung the operator's call for ever, and a door that kept talking
// grew it without end.
async fn answer_of(wire: &'static [u8]) -> Result<(u16, serde_json::Value), String> {
let mut r = wire;
tokio::time::timeout(
D::from_secs(10),
read_answer(&mut r, MAX_ANSWER, D::from_secs(5)),
)
.await
.expect("the reading ends")
}
#[tokio::test]
async fn an_answer_cut_short_of_its_content_length_is_refused() {
let cut = answer_of(
b"HTTP/1.1 200 OK\r\nContent-Length: 40\r\nConnection: close\r\n\r\n{\"outcome\": \"OK\"}",
)
.await;
assert!(cut.is_err(), "{cut:?}");
let whole = answer_of(
b"HTTP/1.1 200 OK\r\nContent-Length: 17\r\nConnection: close\r\n\r\n{\"outcome\": \"OK\"}",
)
.await
.unwrap();
assert_eq!(whole, (200, serde_json::json!({"outcome": "OK"})));
}
#[tokio::test]
async fn a_chunked_answer_is_read_as_http_not_between_the_outer_braces() {
let two = answer_of(b"HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n{\"line\":\r\n6\r\n \"no\"}\r\n0\r\n\r\n").await.unwrap();
assert_eq!(two, (403, serde_json::json!({"line": "no"})));
let unfinished =
answer_of(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n8\r\n{\"line\":\r\n")
.await;
assert!(
unfinished.is_err(),
"a chunked body without its last chunk is cut: {unfinished:?}"
);
}
#[tokio::test]
async fn a_status_line_that_is_not_http_is_refused() {
assert!(
answer_of(b"SSH-2.0-OpenSSH_9.6 {\"outcome\": \"OK\"}\r\n\r\n{}")
.await
.is_err()
);
}
/// A door that answers for ever: `MAX_ANSWER` and more, then silence.
struct Endless(u64);
impl tokio::io::AsyncRead for Endless {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if self.0 == 0 {
return std::task::Poll::Pending;
}
let n = (buf.remaining() as u64).min(self.0).min(64 << 10) as usize;
buf.put_slice(&vec![b' '; n]);
self.0 -= n as u64;
std::task::Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn an_answer_past_the_bound_is_refused_and_not_read_to_its_end() {
let mut endless = tokio::io::AsyncReadExt::chain(
&b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{"[..],
Endless(4 * MAX_ANSWER),
);
let read = tokio::time::timeout(
D::from_secs(10),
read_answer::<serde_json::Value, _>(&mut endless, MAX_ANSWER, D::from_secs(60)),
)
.await;
let read = read.expect("refused at the bound, not read until the door stops");
assert!(read.is_err(), "{read:?}");
}
#[tokio::test]
async fn a_door_that_goes_silent_is_given_up_on() {
let (mut near, mut far) = tokio::io::duplex(1024);
tokio::io::AsyncWriteExt::write_all(
&mut far,
b"HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{\"out",
)
.await
.unwrap();
let read = tokio::time::timeout(
D::from_secs(10),
read_answer::<serde_json::Value, _>(&mut near, MAX_ANSWER, D::from_millis(200)),
)
.await;
let read = read.expect("the wait is the reader's, not the door's");
assert!(read.is_err(), "{read:?}");
drop(far);
}