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/web/hole/tests.rs

use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use tower::ServiceExt as _;

/// A stand-in control plane on loopback (h2c, as gunnar's listener is): every
/// call is answered trailers-only, OK, with what it saw echoed in headers.
async fn upstream() -> (String, Arc<AtomicUsize>) {
    let seen = Arc::new(AtomicUsize::new(0));
    let s2 = Arc::clone(&seen);
    let app = Router::new().fallback(move |req: axum::extract::Request| {
        let seen = Arc::clone(&s2);
        async move {
            seen.fetch_add(1, Ordering::SeqCst);
            let auth = req
                .headers()
                .get("gunnar-authorization")
                .cloned()
                .unwrap_or(HeaderValue::from_static("none"));
            let mut r = grpc_answer(0, "");
            r.headers_mut().insert(
                "x-saw-path",
                HeaderValue::from_str(req.uri().path()).unwrap(),
            );
            r.headers_mut().insert(
                "x-saw-version",
                HeaderValue::from_str(&format!("{:?}", req.version())).unwrap(),
            );
            r.headers_mut().insert("x-saw-auth", auth);
            r
        }
    });
    let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = l.local_addr().unwrap();
    tokio::spawn(async move {
        let _ = axum::serve(l, app).await;
    });
    (addr.to_string(), seen)
}

fn face() -> Router {
    Router::new().fallback(|| async { "the console\n" })
}

fn call(
    path: &str,
    version: Version,
    ct: &str,
    from: [u8; 4],
    auth: Option<&str>,
) -> Request<Body> {
    let mut b = Request::builder()
        .method("POST")
        .uri(format!("https://gunnar.mock.invalid{path}"))
        .version(version)
        .header(header::CONTENT_TYPE, ct);
    if let Some(a) = auth {
        b = b.header("gunnar-authorization", a);
    }
    let mut req = b.body(Body::empty()).unwrap();
    req.extensions_mut()
        .insert(ConnectInfo(SocketAddr::from((from, 40000))));
    req
}

fn status(r: &Response<Body>) -> Option<u32> {
    grpc_status(r.headers())
}

fn audited(hole: &Hole) -> Arc<Mutex<Vec<Forwarded>>> {
    let lines = Arc::new(Mutex::new(Vec::new()));
    let l2 = Arc::clone(&lines);
    hole.set_audit(Arc::new(move |f: &Forwarded| {
        l2.lock().unwrap().push(f.clone())
    }));
    lines
}

/// The host every [`call`] asks for.
const NAME: &str = "gunnar.mock.invalid";

/// A hole that answers on [`NAME`] for any client address (the existing
/// behaviour tests are about the carrying, not the rules).
fn open_hole() -> Hole {
    let hole = Hole::new();
    hole.set_names(&[NAME.to_string()]);
    hole.set_reach(Reach::Public);
    hole
}

/// [`call`] for another host.
fn call_on(host: &str, path: &str, from: [u8; 4]) -> Request<Body> {
    let mut req = call(path, Version::HTTP_2, "application/grpc", from, None);
    *req.uri_mut() = format!("https://{host}{path}").parse().unwrap();
    req
}

const CRED: &str =
    "gunnar-sshsig-v2 SHA256:abcDEF123 1700000000000 nonce-nonce-nonce Repos.List U1NIU0lH";

#[test]
fn a_grpc_call_is_http2_with_the_grpc_content_type_and_nothing_else_is() {
    let r = |v, ct: &str| {
        Request::builder()
            .version(v)
            .header(header::CONTENT_TYPE, ct)
            .body(())
            .unwrap()
    };
    assert!(is_grpc(&r(Version::HTTP_2, "application/grpc")));
    assert!(is_grpc(&r(Version::HTTP_2, "application/grpc+proto")));
    assert!(
        !is_grpc(&r(Version::HTTP_11, "application/grpc")),
        "HTTP/1 is never the hole's"
    );
    assert!(
        !is_grpc(&r(Version::HTTP_2, "application/grpc-web")),
        "gRPC-Web is the console's"
    );
    assert!(!is_grpc(&r(Version::HTTP_2, "application/json")));
    assert!(!is_grpc(
        &Request::builder()
            .version(Version::HTTP_2)
            .body(())
            .unwrap()
    ));
}

#[test]
fn only_the_control_planes_own_services_are_carried() {
    assert_eq!(service_of("/gunnar.v1.Repos/List"), Some("gunnar.v1.Repos"));
    assert_eq!(
        method_of("/gunnar.v1.Repos/List").as_deref(),
        Some("Repos.List")
    );
    assert!(allowed("/gunnar.v1.Repos/List"));
    assert!(allowed("/gunnar.v1.Admin/Health"));
    for refused in [
        "/grpc.health.v1.Health/Check",
        "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
        "/gunnar.v1.Nope/X",
        "/gunnar.v1.Repos",
        "/gunnar.v1.Repos/",
        "/gunnar.v1.Repos/List/more",
        "/",
        "/api/repos",
    ] {
        assert!(!allowed(refused), "{refused} is refused");
    }
}

#[test]
fn the_caller_is_named_as_its_credential_names_itself() {
    let mut h = HeaderMap::new();
    assert_eq!(claimed_caller(&h), "anonymous");
    h.insert("gunnar-authorization", HeaderValue::from_static(CRED));
    assert_eq!(claimed_caller(&h), "SHA256:abcDEF123");
    h.insert(
        "gunnar-authorization",
        HeaderValue::from_static("Bearer xyz"),
    );
    assert_eq!(
        claimed_caller(&h),
        "anonymous",
        "not gunnar's scheme: no name taken from it"
    );
}

#[test]
fn the_upstream_uri_keeps_the_path_and_names_the_control_plane() {
    let u = upstream_uri(
        "10.44.0.2:50051",
        &"https://gunnar.rs/gunnar.v1.Repos/List".parse().unwrap(),
    )
    .unwrap();
    assert_eq!(u.to_string(), "http://10.44.0.2:50051/gunnar.v1.Repos/List");
}

#[test]
fn the_fronts_own_answer_is_a_trailers_only_grpc_status() {
    let r = grpc_answer(RESOURCE_EXHAUSTED, "too many 100%");
    assert_eq!(r.status(), StatusCode::OK);
    assert_eq!(r.headers()[header::CONTENT_TYPE], "application/grpc");
    assert_eq!(status(&r), Some(8));
    assert_eq!(r.headers()["grpc-message"], "too many 100%25");
}

/// ★ The call is carried as it came — its path, its own credential, HTTP/2 — and
/// audited with the status the server answered, as the caller its credential names.
#[tokio::test(flavor = "multi_thread")]
async fn a_call_is_carried_with_the_callers_own_credential_and_audited() {
    let (to, seen) = upstream().await;
    let hole = open_hole();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    let r = with_hole(face(), hole.clone())
        .oneshot(call(
            "/gunnar.v1.Repos/List",
            Version::HTTP_2,
            "application/grpc",
            [203, 0, 113, 7],
            Some(CRED),
        ))
        .await
        .unwrap();
    assert_eq!(status(&r), Some(0));
    assert_eq!(r.headers()["x-saw-path"], "/gunnar.v1.Repos/List");
    assert_eq!(r.headers()["x-saw-version"], "HTTP/2.0");
    assert_eq!(
        r.headers()["x-saw-auth"],
        CRED,
        "the caller's own credential, untouched"
    );
    drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    assert_eq!(seen.load(Ordering::SeqCst), 1);
    let l = lines.lock().unwrap().clone();
    assert_eq!(l.len(), 1, "one audit line per call: {l:?}");
    assert_eq!(
        (
            l[0].method.as_str(),
            l[0].caller.as_str(),
            l[0].client.as_str(),
            l[0].status,
            l[0].answered_by
        ),
        (
            "Repos.List",
            "SHA256:abcDEF123",
            "203.0.113.7",
            Some(0),
            "main"
        )
    );
}

/// An unknown service never leaves the front: `UNIMPLEMENTED`, audited.
#[tokio::test(flavor = "multi_thread")]
async fn an_unknown_service_is_refused_at_the_front() {
    let (to, seen) = upstream().await;
    let hole = open_hole();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    let r = with_hole(face(), hole)
        .oneshot(call(
            "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
            Version::HTTP_2,
            "application/grpc",
            [203, 0, 113, 7],
            None,
        ))
        .await
        .unwrap();
    assert_eq!(status(&r), Some(UNIMPLEMENTED));
    assert_eq!(seen.load(Ordering::SeqCst), 0, "nothing reached the main");
    assert_eq!(lines.lock().unwrap()[0].answered_by, "refused-service");
}

/// Past the limit a client gets `RESOURCE_EXHAUSTED` (gRPC's 429), audited; another
/// client address is not charged for it.
#[tokio::test(flavor = "multi_thread")]
async fn past_the_limit_a_client_is_refused_and_another_is_not() {
    let (to, seen) = upstream().await;
    let hole = open_hole();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    hole.set_per_minute(2);
    let app = with_hole(face(), hole);
    let mut codes = Vec::new();
    for _ in 0..3 {
        let r = app
            .clone()
            .oneshot(call(
                "/gunnar.v1.Repos/List",
                Version::HTTP_2,
                "application/grpc",
                [198, 51, 100, 1],
                Some(CRED),
            ))
            .await
            .unwrap();
        codes.push(status(&r));
        drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    }
    assert_eq!(codes, vec![Some(0), Some(0), Some(RESOURCE_EXHAUSTED)]);
    let other = app
        .oneshot(call(
            "/gunnar.v1.Repos/List",
            Version::HTTP_2,
            "application/grpc",
            [198, 51, 100, 2],
            Some(CRED),
        ))
        .await
        .unwrap();
    assert_eq!(
        status(&other),
        Some(0),
        "another address has its own bucket"
    );
    drop(
        axum::body::to_bytes(other.into_body(), 1 << 20)
            .await
            .unwrap(),
    );
    assert_eq!(
        seen.load(Ordering::SeqCst),
        3,
        "the refused call never reached the main"
    );
    assert!(lines
        .lock()
        .unwrap()
        .iter()
        .any(|f| f.answered_by == "rate-limited" && f.client == "198.51.100.1"));
}

/// HTTP/1 (and every non-gRPC request) goes to the face, untouched, even with a
/// gRPC content type; before a primary is known a gRPC call is `UNAVAILABLE`.
#[tokio::test(flavor = "multi_thread")]
async fn http1_is_the_faces_and_with_no_primary_a_call_is_unavailable() {
    let hole = open_hole();
    let lines = audited(&hole);
    let app = with_hole(face(), hole);
    for (v, ct) in [
        (Version::HTTP_11, "application/grpc"),
        (Version::HTTP_11, "text/html"),
        (Version::HTTP_2, "application/json"),
    ] {
        let r = app
            .clone()
            .oneshot(call("/gunnar.v1.Repos/List", v, ct, [203, 0, 113, 7], None))
            .await
            .unwrap();
        let body = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap();
        assert_eq!(&body[..], b"the console\n", "{v:?} {ct} is the face's");
    }
    assert!(
        lines.lock().unwrap().is_empty(),
        "the face's requests are not the hole's to audit"
    );
    let r = app
        .oneshot(call(
            "/gunnar.v1.Repos/List",
            Version::HTTP_2,
            "application/grpc",
            [203, 0, 113, 7],
            None,
        ))
        .await
        .unwrap();
    assert_eq!(status(&r), Some(UNAVAILABLE));
    assert_eq!(lines.lock().unwrap()[0].answered_by, "no-primary");
}

/// The hole follows the primary the console dials (the verdict's overlay, :50051).
#[test]
fn the_hole_goes_where_the_console_goes() {
    use nordisk_pairing::join::{Assigned, JoinReply, Peer};
    use std::net::Ipv4Addr;
    let peer = |name: &str, role, o: u8| Peer {
        name: name.into(),
        role,
        tunnel_pubkey: String::new(),
        overlay: Ipv4Addr::new(10, 44, 0, o),
        endpoint: None,
        utility: Some(Ipv4Addr::new(10, 13, 8, o)),
    };
    let reply = |peers| JoinReply {
        estate: "gunnar.rs".into(),
        me: Assigned {
            name: "front".into(),
            overlay: nordisk_pairing::OVERLAY_FRONT,
        },
        cert_pem: None,
        role: Some(nordisk_pairing::Role::Front),
        primary_overlay: None,
        primary_utility: None,
        peers,
        feed: Default::default(),
    };
    assert_eq!(target_of(&reply(vec![])), None);
    let r = reply(vec![
        peer("twin", nordisk_pairing::Role::Twin, 3),
        peer("main", nordisk_pairing::Role::Primary, 2),
    ]);
    assert_eq!(target_of(&r).as_deref(), Some("10.44.0.2:50051"));
    assert_eq!(target_of(&r), crate::console::target(&r).map(|t| t.control));
    let hole = Hole::new();
    assert!(hole.retarget(target_of(&r)));
    assert!(
        !hole.retarget(target_of(&r)),
        "the same target is no change"
    );
    assert_eq!(hole.target().as_deref(), Some("10.44.0.2:50051"));
}

// ── RA1-1: the hole keeps the face's rules ─────────────────────────────────

/// Of Bootstrap only the two reads are carried; every method that hands a fresh
/// box to its first caller (Enroll above all) is refused at the front.
#[test]
fn of_bootstrap_only_the_reads_are_carried() {
    for read in [
        "/gunnar.v1.Bootstrap/Status",
        "/gunnar.v1.Bootstrap/GrowStatus",
        "/gunnar.v1.Repos/List",
        "/gunnar.v1.Admin/Health",
        "/gunnar.v1.Access/WhoAmI",
    ] {
        assert!(method_carried(read), "{read} is carried");
    }
    for once in [
        "/gunnar.v1.Bootstrap/Enroll",
        "/gunnar.v1.Bootstrap/BecomeTwin",
        "/gunnar.v1.Bootstrap/Restore",
        "/gunnar.v1.Bootstrap/AssignTunnelAddress",
        "/gunnar.v1.Bootstrap/TwinCsr",
        "/gunnar.v1.Bootstrap/InstallTwinCertificate",
        "/gunnar.v1.Bootstrap/Nope",
    ] {
        assert!(!method_carried(once), "{once} is never carried");
    }
    assert!(
        !method_carried("/grpc.health.v1.Health/Check"),
        "a service not carried stays not carried"
    );
}

/// The setting: unset is own-network, the two words are read, anything else is an error.
#[test]
fn the_reach_setting_defaults_to_the_own_network() {
    assert_eq!(Reach::default(), Reach::OwnNetwork);
    assert_eq!(reach_from(None), Ok(Reach::OwnNetwork));
    assert_eq!(reach_from(Some("")), Ok(Reach::OwnNetwork));
    assert_eq!(reach_from(Some("own-network")), Ok(Reach::OwnNetwork));
    assert_eq!(reach_from(Some(" Public ")), Ok(Reach::Public));
    assert!(
        reach_from(Some("everyone")).is_err(),
        "an unknown word is refused by name, never read as public"
    );
    assert_eq!(
        Hole::new().reach(),
        Reach::OwnNetwork,
        "a new hole is own-network"
    );
}

/// The own network: loopback, private, the overlay, an operator's overlay, link-local, ULA.
#[test]
fn the_own_network_is_the_private_ranges_and_the_overlays() {
    use std::net::IpAddr;
    for own in [
        "127.0.0.1",
        "10.44.0.2",
        "10.13.8.9",
        "172.16.0.1",
        "172.31.255.254",
        "192.168.1.5",
        "100.64.0.1",
        "100.127.255.254",
        "169.254.1.1",
        "::1",
        "fd7a:115c:a1e0::1",
        "fc00::1",
        "fe80::1",
        "::ffff:10.0.0.1",
    ] {
        assert!(
            own_network(own.parse::<IpAddr>().unwrap()),
            "{own} is the own network"
        );
    }
    for public in [
        "203.0.113.7",
        "8.8.8.8",
        "172.32.0.1",
        "100.128.0.1",
        "192.169.0.1",
        "2001:db8::1",
        "::ffff:203.0.113.7",
        "0.0.0.0",
    ] {
        assert!(
            !own_network(public.parse::<IpAddr>().unwrap()),
            "{public} is not the own network"
        );
    }
}

/// The console's names are the served names but the callback-only ones.
#[test]
fn the_consoles_names_leave_out_the_callback_only_ones() {
    let feed = std::collections::BTreeMap::from([
        (
            crate::web::FEED_WEB_HOSTS.to_string(),
            "gunnar.rs,www.gunnar.rs,pay.gunnar.rs".to_string(),
        ),
        (
            crate::web::FEED_WEB_CALLBACK_ONLY.to_string(),
            "pay.gunnar.rs".to_string(),
        ),
    ]);
    let cfg = crate::web::WebConfig::from("gunnar.rs", &feed);
    assert_eq!(
        crate::web::console_names(&cfg),
        vec!["gunnar.rs".to_string(), "www.gunnar.rs".to_string()]
    );
}

/// ★ A callback-only name, a name the front does not serve, and a hole with no
/// names yet carry nothing; the console's own name does.
#[tokio::test(flavor = "multi_thread")]
async fn the_hole_answers_on_the_consoles_own_names_only() {
    let (to, seen) = upstream().await;
    let hole = Hole::new();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    let app = with_hole(face(), hole.clone());
    let r = app
        .clone()
        .oneshot(call_on(
            "gunnar.rs",
            "/gunnar.v1.Repos/List",
            [10, 44, 0, 9],
        ))
        .await
        .unwrap();
    assert_eq!(
        status(&r),
        Some(PERMISSION_DENIED),
        "no names yet: nothing is carried"
    );
    hole.set_names(&["gunnar.rs".to_string(), "www.gunnar.rs".to_string()]);
    for host in ["pay.gunnar.rs", "evil.example", "10.44.0.1"] {
        let r = app
            .clone()
            .oneshot(call_on(host, "/gunnar.v1.Bootstrap/Status", [10, 44, 0, 9]))
            .await
            .unwrap();
        assert_eq!(
            status(&r),
            Some(PERMISSION_DENIED),
            "{host} carries no control plane"
        );
    }
    assert_eq!(seen.load(Ordering::SeqCst), 0, "nothing reached the main");
    assert!(
        lines
            .lock()
            .unwrap()
            .iter()
            .all(|f| f.answered_by == "refused-name"),
        "{:?}",
        lines.lock().unwrap()
    );
    for host in ["gunnar.rs", "WWW.gunnar.rs"] {
        let r = app
            .clone()
            .oneshot(call_on(host, "/gunnar.v1.Bootstrap/Status", [10, 44, 0, 9]))
            .await
            .unwrap();
        assert_eq!(status(&r), Some(0), "{host} is the console's");
        drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    }
    assert_eq!(seen.load(Ordering::SeqCst), 2);
}

/// ★ Own-network (the default): a public client is refused at the front, the
/// estate's network and an operator's overlay are carried; `public` carries it.
#[tokio::test(flavor = "multi_thread")]
async fn by_default_only_the_own_network_reaches_the_hole() {
    let (to, seen) = upstream().await;
    let hole = Hole::new();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    hole.set_names(&[NAME.to_string()]);
    let app = with_hole(face(), hole.clone());
    let r = app
        .clone()
        .oneshot(call_on(NAME, "/gunnar.v1.Repos/List", [203, 0, 113, 7]))
        .await
        .unwrap();
    assert_eq!(
        status(&r),
        Some(PERMISSION_DENIED),
        "a public client is refused by default"
    );
    assert_eq!(seen.load(Ordering::SeqCst), 0);
    assert_eq!(lines.lock().unwrap()[0].answered_by, "refused-network");
    for own in [[10, 44, 0, 5], [192, 168, 7, 7], [100, 101, 1, 1]] {
        let r = app
            .clone()
            .oneshot(call_on(NAME, "/gunnar.v1.Repos/List", own))
            .await
            .unwrap();
        assert_eq!(status(&r), Some(0), "{own:?} is the own network");
        drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    }
    hole.set_reach(Reach::Public);
    let r = app
        .oneshot(call_on(NAME, "/gunnar.v1.Repos/List", [203, 0, 113, 7]))
        .await
        .unwrap();
    assert_eq!(status(&r), Some(0), "`public` carries a public client");
    drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    assert_eq!(seen.load(Ordering::SeqCst), 4);
}

/// ★ Bootstrap.Enroll never leaves the front, from anywhere, on any name; the
/// status read does.
#[tokio::test(flavor = "multi_thread")]
async fn enroll_is_refused_at_the_hole_and_status_is_carried() {
    let (to, seen) = upstream().await;
    let hole = open_hole();
    let lines = audited(&hole);
    hole.retarget(Some(to));
    let app = with_hole(face(), hole);
    for from in [[127, 0, 0, 1], [10, 44, 0, 2], [203, 0, 113, 7]] {
        let r = app
            .clone()
            .oneshot(call_on(NAME, "/gunnar.v1.Bootstrap/Enroll", from))
            .await
            .unwrap();
        assert_eq!(
            status(&r),
            Some(PERMISSION_DENIED),
            "Enroll from {from:?} is refused"
        );
        let said = r
            .headers()
            .get("grpc-message")
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_string();
        assert!(
            said.contains("front-hole-method"),
            "refused by name: {said}"
        );
    }
    assert_eq!(seen.load(Ordering::SeqCst), 0, "no Enroll reached the main");
    assert!(lines
        .lock()
        .unwrap()
        .iter()
        .all(|f| f.answered_by == "refused-method" && f.method == "Bootstrap.Enroll"));
    let r = app
        .oneshot(call_on(
            NAME,
            "/gunnar.v1.Bootstrap/Status",
            [203, 0, 113, 7],
        ))
        .await
        .unwrap();
    assert_eq!(
        status(&r),
        Some(0),
        "Bootstrap.Status is carried (gunnar control status)"
    );
    drop(axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap());
    assert_eq!(seen.load(Ordering::SeqCst), 1);
}

/// ★ RA1-14: **the reach is the site's feed**: `front_cli_hole = public` (fed only to a site
/// whose declaration says `cli_hole_public`, gunnar.rs) opens the hole to any address; unfed,
/// empty or any other word keeps it to the estate's own network (an unknown word is said).
#[test]
fn the_holes_reach_is_the_sites_feed() {
    let feed = |v: Option<&str>| {
        v.map(|v| std::collections::BTreeMap::from([(FEED_CLI_HOLE.to_string(), v.to_string())]))
            .unwrap_or_default()
    };
    assert_eq!(FEED_CLI_HOLE, "front_cli_hole");
    assert_eq!(reach_of(&feed(Some("public"))), Reach::Public);
    for v in [None, Some(""), Some("own-network"), Some("everyone")] {
        assert_eq!(reach_of(&feed(v)), Reach::OwnNetwork, "{v:?}");
    }
}