Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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