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

nordisk/monetize

crates/monetize-cli/tests/front_certs.rs

//! ★ **`monetize front certs --at https://<site>/<its certificate door>`** (A7, the ACME lane's
//! leftover): the ONE box has `monetize box certs`; Holger's and Gunnar's fronts showed their
//! certificates only on their console pages. The verb is a thin client over the status doors
//! the ACME lane added — Holger's `GET/POST /front/certs[/try]`, Gunnar's
//! `GET/POST /api/front/certs[/try]` — with an admin's credential read from a file, and the
//! front's own words printed. Nothing is decided here. RA1-3: the door is the address the
//! operator names; the CLI holds no product's door path.
//!
//! The front here is a TLS server in the test answering with a report in edda estate-front's
//! shape (`web::status::report_json`: monetize-admin's `tests/front_certs_cli.rs` renders the
//! REAL one); the CLI is the real `monetize` binary.

use std::io::{BufRead as _, BufReader, Read as _, Write as _};
use std::net::TcpListener;
use std::path::Path;
use std::sync::{Arc, Mutex};

/// One request the front saw: method, path, `authorization`, `cookie`, body.
type Seen = Arc<Mutex<Vec<(String, String, String, String, String)>>>;

const TOKEN: &str = "holger-admin-token-mock-never-printed";
const COOKIE: &str = "gunnar_session=mock-admin-session-never-printed";

/// **The front**: TLS on loopback under its own self-signed certificate (written to
/// `ca.pem` for `--ca`), answering `report` to an admin's read and `{"words"}` to a try now,
/// and the front's own refusal to anyone else.
fn front(dir: &Path, report: serde_json::Value) -> (String, Seen) {
    let ck = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap();
    std::fs::write(dir.join("ca.pem"), ck.cert.pem()).unwrap();
    let certs = vec![ck.cert.der().clone()];
    let key = rustls::pki_types::PrivateKeyDer::try_from(ck.signing_key.serialize_der()).unwrap();
    let cfg = Arc::new(rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())).with_safe_default_protocol_versions().unwrap().with_no_client_auth().with_single_cert(certs, key).unwrap());
    let l = TcpListener::bind("127.0.0.1:0").unwrap();
    let at = format!("https://127.0.0.1:{}", l.local_addr().unwrap().port());
    let seen: Seen = Arc::default();
    let s = seen.clone();
    std::thread::spawn(move || {
        for tcp in l.incoming().flatten() {
            let (cfg, s, report) = (cfg.clone(), s.clone(), report.clone());
            std::thread::spawn(move || {
                let conn = rustls::ServerConnection::new(cfg).unwrap();
                let mut tls = rustls::StreamOwned::new(conn, tcp);
                let mut r = BufReader::new(&mut tls);
                let mut line = String::new();
                if r.read_line(&mut line).is_err() {
                    return;
                }
                let mut p = line.split_whitespace();
                let (method, path) = (p.next().unwrap_or("").to_string(), p.next().unwrap_or("").to_string());
                let (mut len, mut auth, mut cookie) = (0usize, String::new(), String::new());
                loop {
                    let mut h = String::new();
                    if r.read_line(&mut h).is_err() || h.trim().is_empty() {
                        break;
                    }
                    let (k, v) = h.split_once(':').unwrap_or((&h, ""));
                    match k.trim().to_ascii_lowercase().as_str() {
                        "content-length" => len = v.trim().parse().unwrap_or(0),
                        "authorization" => auth = v.trim().to_string(),
                        "cookie" => cookie = v.trim().to_string(),
                        _ => {}
                    }
                }
                let mut body = vec![0u8; len];
                let _ = r.read_exact(&mut body);
                let body = String::from_utf8_lossy(&body).to_string();
                s.lock().unwrap().push((method.clone(), path.clone(), auth.clone(), cookie.clone(), body.clone()));
                let admin = auth == format!("Bearer {TOKEN}") || cookie == COOKIE;
                let (status, text) = if !admin {
                    (401, "REFUSED [front-certs-signed-out] sign in as an admin\n".to_string())
                } else if method == "POST" && path.ends_with("/certs/try") {
                    let host = serde_json::from_str::<serde_json::Value>(&body).unwrap()["host"].as_str().unwrap_or_default().to_string();
                    (200, serde_json::json!({"words": format!("{host}: tried now")}).to_string())
                } else if method == "GET" && path.ends_with("/certs") {
                    (200, report.to_string())
                } else {
                    (404, "no such door\n".to_string())
                };
                let _ = write!(tls, "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", text.len());
                let _ = tls.flush();
            });
        }
    });
    (at, seen)
}

fn monetize(args: &[&str]) -> (bool, String, String) {
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_monetize")).args(args).env_remove("MONETIZE_DOOR").env_remove("MONETIZE_MATERIAL").output().unwrap();
    (out.status.success(), String::from_utf8_lossy(&out.stdout).to_string(), String::from_utf8_lossy(&out.stderr).to_string())
}

#[test]
fn front_certs_shows_holgers_and_gunnars_certificates_through_their_own_doors() {
    let dir = tempfile::tempdir().unwrap();
    let report = serde_json::json!({"certificates": [{
        "host": "holger.example", "hosts": ["holger.example", "www.holger.example"],
        "valid_until": null, "next_attempt": 1_761_480_000, "failures": 1, "try_now": false, "alert": null,
        "lines": ["no valid certificate yet", "next attempt in 59 min (1 failed validation)", "last: dns: holger.example does not point at this box (it resolves to 198.51.100.9)"],
    }]});
    let lines: Vec<String> = report["certificates"][0]["lines"].as_array().expect("the report has lines").iter().filter_map(|l| l.as_str().map(String::from)).collect();
    assert!(!lines.is_empty(), "{report}");
    let (at, seen) = front(dir.path(), report);
    let ca = dir.path().join("ca.pem").display().to_string();
    let token = dir.path().join("token");
    std::fs::write(&token, format!("{TOKEN}\n")).unwrap();
    let cookie = dir.path().join("cookie");
    std::fs::write(&cookie, COOKIE).unwrap();
    let token = token.display().to_string();
    let cookie = cookie.display().to_string();

    // Holger: its admin's bearer on /front/certs; the front's lines printed, each.
    let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token]);
    assert!(ok, "{out}{err}");
    assert!(out.contains("holger.example"), "{out}");
    for l in &lines {
        assert!(out.contains(l.as_str()), "the front's line {l:?} is said as it said it: {out}");
    }
    // Gunnar: the console's admin session on /api/front/certs.
    let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/api/front/certs"), "--ca", &ca, "--cookie-file", &cookie]);
    assert!(ok && out.contains("holger.example"), "{out}{err}");
    // Try now, with the host named.
    let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token, "--try", "holger.example"]);
    assert!(ok && out.contains("holger.example: tried now"), "{out}{err}");
    // No admin's credential: the front's own refusal, and a failure.
    let wrong = dir.path().join("wrong");
    std::fs::write(&wrong, "not-the-token").unwrap();
    let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &wrong.display().to_string()]);
    assert!(!ok && err.contains("[front-certs-signed-out]"), "{out}{err}");

    let seen = seen.lock().unwrap().clone();
    let doors: Vec<(String, String)> = seen.iter().map(|(m, p, ..)| (m.clone(), p.clone())).collect();
    assert_eq!(doors, [("GET", "/front/certs"), ("GET", "/api/front/certs"), ("POST", "/front/certs/try"), ("GET", "/front/certs")].map(|(m, p)| (m.to_string(), p.to_string())).to_vec());
    assert_eq!(seen[0].2, format!("Bearer {TOKEN}"), "the token file's line, trimmed, as the bearer");
    assert_eq!((seen[1].2.as_str(), seen[1].3.as_str()), ("", COOKIE), "the session file as the cookie, no bearer");
    assert_eq!(serde_json::from_str::<serde_json::Value>(&seen[2].4).unwrap(), serde_json::json!({"host": "holger.example"}));
    let (_, o, e) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token]);
    assert!(!o.contains(TOKEN) && !e.contains(TOKEN), "the credential is never said");
}

/// Refused by name before anything is asked: an address that names no door (or the old form
/// that named a product), no `--at`, a plain-http address, and no credential.
#[test]
fn front_certs_refuses_what_it_cannot_ask_by_name() {
    for (args, why) in [
        (vec!["front", "certs", "--at", "https://x.example", "--token-file", "/dev/null"], "[front-door]"),
        (vec!["front", "certs", "someproduct", "--at", "https://x.example/front/certs", "--token-file", "/dev/null"], "[front-door]"),
        (vec!["front", "certs", "--token-file", "/dev/null"], "[front-at]"),
        (vec!["front", "certs", "--at", "http://x.example/front/certs", "--token-file", "/dev/null"], "[front-at]"),
        (vec!["front", "certs", "--at", "https://x.example/api/front/certs"], "[front-credential]"),
    ] {
        let (ok, out, err) = monetize(&args);
        assert!(!ok && err.contains(why), "{args:?}: {out}{err}");
    }
}