v0.7.0: Secure Boot chain, boot rules + decision webhook, tokenized answer files

Three features, all zero-toggle and principle-clean (single static musl
binary, container-first, no test certs, no client trust-store changes).

Secure Boot via signed shim+GRUB (automatic):
- The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin
  -> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses
  to execute it — indistinguishable from a failed chainload — so after
  two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed
  shimx64.efi, which loads the signed GRUB, which fetches a
  server-rendered grub.cfg. Fully signed chain, SB stays on.
- scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort)
  from the official Fedora 43 packages and ships the EFI binaries
  byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio.
- New grub_script renderer (Linux kernel entries only — signed GRUB only
  boots signed kernels; sanboot/wimboot have no signed equivalent and
  are omitted with an explanatory menu line).
- TFTP server gains a DynamicAsset hook for server-rendered names
  (grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for
  native UEFI HTTP Boot chains. Arch-aware fallback walks back down the
  ladder where no shim exists (BIOS, IA32).

Boot rules + decision webhook (open 'Matrix Boot'):
- Ordered first-match-wins rules over MAC prefix + client arch (the DHCP
  proxy now bakes arch into the boot.ipxe chain URL), generalizing
  per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules;
  rules editor + webhook field on the Hosts tab.
- Optional pixiecore-style webhook: unmatched boots GET
  <url>?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open
  with a 2s budget — a dead endpoint can never block PXE.
- Decision order: exact pin -> rules -> webhook -> menu. Empty config
  is byte-for-byte the previous behavior.

Tokenized answer files (the post-WDS/CVE-2026-0386 hardening):
- Every generated unattended URL (inst.ks / preseed url / autoinstall
  seed) now carries a 4h boot-scoped token; /unattended/{id} and the
  cloud-init seed routes require it (or an operator session) once an
  admin exists. Stops answer-file credential harvesting by anything
  else on the network. No toggle; setup-mode installs stay open.

Validation: clippy clean, fmt clean, 290 workspace tests green
(+18 new across boot_tokens, boot_rules, arch ladder, escalation,
grub renderer, and four new full-flow integration tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-09 20:17:18 -04:00
co-authored by Claude Opus 4.8
parent 7f25bb681c
commit 3a32d65fb7
21 changed files with 1396 additions and 68 deletions
+141
View File
@@ -115,6 +115,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings,
hosts,
boot_log,
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
boot_tokens: openpxe_core::BootTokens::new(),
branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin,
@@ -2610,3 +2612,142 @@ async fn acs_garbage_is_rejected_without_500() {
assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp));
}
// ─── v0.7.0: tokenized answer files + boot rules ────────────────────────────
#[tokio::test]
async fn unattended_requires_token_once_admin_exists() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload an answer file while in setup mode (everything open).
let (ct, body) =
multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Pre-setup, the file serves openly (bootstrap parity with the
// auth middleware).
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::OK);
// Create the admin → the gate arms.
let (s, _, cookies) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
let session = session_value(&cookies).unwrap();
// Bare fetch (the CVE-2026-0386 harvesting pattern) is refused.
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// Garbage token is refused.
let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// A token minted for a *different* file is refused.
let other = state.boot_tokens.mint("some-other-file");
let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// The boot-scoped token OpenPXE mints into generated URLs passes.
let tok = state.boot_tokens.mint(&id);
let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("rootpw"));
// A logged-in operator (browser testing) passes too.
let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn boot_script_for_pinned_unattended_carries_live_token() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let mac = "aa:bb:cc:dd:ee:71";
let pin =
format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#);
let (s, _) = post_json(&app, "/api/hosts", &pin).await;
assert_eq!(s, StatusCode::CREATED);
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b).into_owned();
// The injected inst.ks URL ends with a token that is live for the file.
let tok = script
.split("t=")
.nth(1)
.and_then(|rest| rest.split_whitespace().next())
.expect("kernel arg should carry t=<token>");
assert!(
state.boot_tokens.check(tok, &ks_id),
"token in boot script must be live:\n{script}"
);
}
#[tokio::test]
async fn boot_rules_match_and_persist_via_api() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
// Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry.
let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// The config reads back (prefix normalized to colons).
let (s, b) = get(&app, "/api/boot-rules").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc");
// A matching client short-circuits to the target...
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b);
assert!(
script.contains("boot rule -> fake-alpine-linux"),
"rule did not chain:\n{script}"
);
// ...while a non-matching one still gets the menu.
let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(
String::from_utf8_lossy(&b).contains("menu"),
"non-matching client should see the menu"
);
}
#[tokio::test]
async fn arch_selective_rule_ignores_other_arches() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// x64 client: no match → menu.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(!String::from_utf8_lossy(&b).contains("boot rule ->"));
// arm64 client: match.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux"));
}