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:?}");
}
}