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:
co-authored by
Claude Opus 4.8
parent
7f25bb681c
commit
3a32d65fb7
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user