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
+85
-6
@@ -23,13 +23,21 @@ pub enum ClientArch {
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
/// Which iPXE network backend to advertise to a client (v0.6.1).
|
||||
/// Which boot binary family to advertise to a client (v0.6.1, extended
|
||||
/// v0.7.0).
|
||||
///
|
||||
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
|
||||
/// stack, via `snponly`/`undionly`) and only escalates a specific MAC to
|
||||
/// [`DriverMode::Builtin`] (iPXE's bundled NIC drivers) automatically, when a
|
||||
/// firmware-net boot fails to chainload. There is no operator toggle — the
|
||||
/// DHCP proxy decides per client.
|
||||
/// stack, via `snponly`/`undionly`) and escalates a specific MAC
|
||||
/// automatically when a boot never completes its handoff:
|
||||
/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
|
||||
/// proxy decides per client.
|
||||
///
|
||||
/// The `Shim` rung (v0.7.0) covers Secure Boot: firmware with SB enabled
|
||||
/// downloads our unsigned iPXE fine but refuses to *execute* it, which
|
||||
/// looks exactly like a failed chainload. After both iPXE builds go
|
||||
/// unconfirmed, the client is offered the Microsoft-signed shim, which
|
||||
/// loads the signed GRUB, which fetches a server-rendered menu — a fully
|
||||
/// signed chain that boots signed distro kernels with SB still on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriverMode {
|
||||
@@ -40,6 +48,9 @@ pub enum DriverMode {
|
||||
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
|
||||
/// hardware whose firmware NIC stack is missing or buggy.
|
||||
Builtin,
|
||||
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
|
||||
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
|
||||
Shim,
|
||||
}
|
||||
|
||||
impl ClientArch {
|
||||
@@ -88,20 +99,43 @@ impl ClientArch {
|
||||
// IA32 UEFI.
|
||||
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
|
||||
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi",
|
||||
// No signed Shim chain for BIOS (no Secure Boot there) or
|
||||
// IA32 UEFI (Fedora publishes no 32-bit shim; SB-on IA32
|
||||
// clients are vanishingly rare). Same outcome as the
|
||||
// no-binary arches below, listed separately for the comment.
|
||||
#[allow(clippy::match_same_arms)]
|
||||
(Self::LegacyX86 | Self::Ia32Uefi, DriverMode::Shim) => return None,
|
||||
// x86_64 UEFI — the overwhelmingly common modern client.
|
||||
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
|
||||
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
|
||||
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
|
||||
// ARM64 UEFI.
|
||||
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
|
||||
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi",
|
||||
(Self::Arm64Uefi, DriverMode::Shim) => "shimaa64.efi",
|
||||
// ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary
|
||||
// for this arch in either mode. Unknown arches likewise. Return
|
||||
// for this arch in any mode. Unknown arches likewise. Return
|
||||
// None so the DHCP proxy declines rather than advertising a file
|
||||
// we can't serve.
|
||||
(Self::Arm32Uefi | Self::Unknown(_), _) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`Self::ipxe_bootfile_mode`], but walks back down the
|
||||
/// escalation ladder (`Shim → Builtin → Firmware`) when the requested
|
||||
/// mode has no binary for this arch — e.g. a BIOS client whose
|
||||
/// escalation state reached `Shim` (BIOS has no Secure Boot) falls
|
||||
/// back to the all-drivers build instead of being ignored.
|
||||
#[must_use]
|
||||
pub fn bootfile_with_fallback(self, mode: DriverMode) -> Option<&'static str> {
|
||||
let ladder: &[DriverMode] = match mode {
|
||||
DriverMode::Shim => &[DriverMode::Shim, DriverMode::Builtin, DriverMode::Firmware],
|
||||
DriverMode::Builtin => &[DriverMode::Builtin, DriverMode::Firmware],
|
||||
DriverMode::Firmware => &[DriverMode::Firmware],
|
||||
};
|
||||
ladder.iter().find_map(|m| self.ipxe_bootfile_mode(*m))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -233,6 +267,51 @@ mod tests {
|
||||
assert_eq!(DriverMode::default(), DriverMode::Firmware);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_mode_maps_to_signed_chain_on_uefi_only() {
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
Some("shimx64.efi")
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
Some("shimaa64.efi")
|
||||
);
|
||||
// No Secure Boot on BIOS, no published 32-bit shim.
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_walks_down_the_ladder() {
|
||||
// BIOS escalated to Shim → falls back to the all-drivers build.
|
||||
assert_eq!(
|
||||
ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim),
|
||||
Some("ipxe.pxe")
|
||||
);
|
||||
// UEFI x64 at Shim gets the real shim.
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim),
|
||||
Some("shimx64.efi")
|
||||
);
|
||||
// Plain modes are unchanged.
|
||||
assert_eq!(
|
||||
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware),
|
||||
Some("snponly.efi")
|
||||
);
|
||||
// Arches with nothing stay None.
|
||||
assert_eq!(
|
||||
ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Label-based boot rules + boot-decision webhook (v0.7.0).
|
||||
//!
|
||||
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
|
||||
//! into ordered, first-match-wins rules over what the boot chain knows
|
||||
//! about a client — MAC prefix (OUI or longer) and firmware
|
||||
//! architecture — plus an optional outbound webhook so external
|
||||
//! automation (CMDB, netbox, a shell script) can decide the boot target
|
||||
//! per machine, pixiecore-style.
|
||||
//!
|
||||
//! Decision order in the boot script handler, most-specific first:
|
||||
//! 1. exact per-MAC host binding (operator pin)
|
||||
//! 2. first matching enabled rule here
|
||||
//! 3. webhook, if configured (fail-open: timeout/error → menu)
|
||||
//! 4. interactive menu
|
||||
//!
|
||||
//! With no rules and no webhook configured the behavior is byte-for-byte
|
||||
//! what it was before this feature existed — no toggles to flip.
|
||||
//!
|
||||
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
|
||||
//! authoritative, disk is a crash cache, corruption falls back to empty"
|
||||
//! policy as the host bindings — a bad rules file must never block PXE.
|
||||
|
||||
use crate::host_bindings::normalize_mac;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// One ordered rule. All present (non-empty) selectors must match —
|
||||
/// empty selector fields match anything, so a rule with only `arch` set
|
||||
/// applies to every client of that architecture.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootRule {
|
||||
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
|
||||
/// an OUI, or longer). Empty = any MAC.
|
||||
#[serde(default)]
|
||||
pub mac_prefix: String,
|
||||
/// Client architecture selector — matches `ClientArch::as_str()`
|
||||
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
|
||||
#[serde(default)]
|
||||
pub arch: String,
|
||||
/// Boot entry id (a `BootEntry::id`) or reserved menu name
|
||||
/// (`_local`, `_queue`, …) to chain to when this rule matches.
|
||||
pub target: String,
|
||||
/// Rules can be parked without deleting them.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The whole persisted config: ordered rules + optional webhook.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BootRulesConfig {
|
||||
pub rules: Vec<BootRule>,
|
||||
/// Optional boot-decision webhook URL. When set, unmatched boots GET
|
||||
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
|
||||
/// reply chains to that target. Anything else (404, timeout, bad
|
||||
/// JSON) falls through to the menu. Empty = disabled.
|
||||
pub webhook_url: String,
|
||||
}
|
||||
|
||||
/// Store for the rules config. Cheap to clone; locks held briefly.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BootRulesStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<BootRulesConfig>>,
|
||||
}
|
||||
|
||||
impl BootRulesStore {
|
||||
/// Load from `work_dir/boot_rules.json`, or start empty if absent /
|
||||
/// unreadable.
|
||||
#[must_use]
|
||||
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("boot_rules.json");
|
||||
let inner = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "openpxe::boot_rules",
|
||||
"boot_rules.json present but unreadable ({e}); starting empty"
|
||||
);
|
||||
BootRulesConfig::default()
|
||||
}
|
||||
},
|
||||
Err(_) => BootRulesConfig::default(),
|
||||
};
|
||||
Self {
|
||||
path: Arc::new(path),
|
||||
inner: Arc::new(RwLock::new(inner)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current config snapshot (for the API / UI).
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> BootRulesConfig {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Replace the whole config (the UI saves the full table at once —
|
||||
/// rules are ordered, so partial updates would be ambiguous).
|
||||
pub fn replace(&self, mut cfg: BootRulesConfig) {
|
||||
for r in &mut cfg.rules {
|
||||
r.mac_prefix = normalize_mac(&r.mac_prefix);
|
||||
r.arch = r.arch.trim().to_ascii_lowercase();
|
||||
r.target = r.target.trim().to_string();
|
||||
r.note = r.note.trim().to_string();
|
||||
}
|
||||
cfg.webhook_url = cfg.webhook_url.trim().to_string();
|
||||
*self.inner.write() = cfg;
|
||||
self.persist();
|
||||
}
|
||||
|
||||
/// Webhook URL, when configured.
|
||||
#[must_use]
|
||||
pub fn webhook_url(&self) -> Option<String> {
|
||||
let g = self.inner.read();
|
||||
if g.webhook_url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(g.webhook_url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// First enabled rule matching `(mac, arch)`, in stored order.
|
||||
/// `arch` is the `ClientArch::as_str()` form when the boot chain
|
||||
/// passed one along, `None` otherwise (older chains).
|
||||
#[must_use]
|
||||
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
|
||||
let mac = normalize_mac(mac);
|
||||
let g = self.inner.read();
|
||||
for r in &g.rules {
|
||||
if !r.enabled || r.target.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !r.arch.is_empty() {
|
||||
// An arch-selective rule can only match when the chain
|
||||
// told us the client's arch.
|
||||
match arch {
|
||||
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
return Some(r.target.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
|
||||
BootRule {
|
||||
mac_prefix: mac_prefix.into(),
|
||||
arch: arch.into(),
|
||||
target: target.into(),
|
||||
enabled: true,
|
||||
note: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_matches_nothing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
assert!(s
|
||||
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
|
||||
.is_none());
|
||||
assert!(s.webhook_url().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_match_wins_in_order() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
s.replace(BootRulesConfig {
|
||||
rules: vec![
|
||||
rule("aa:bb:cc", "", "rack-image"),
|
||||
rule("", "", "catch-all"),
|
||||
],
|
||||
webhook_url: String::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
|
||||
Some("rack-image")
|
||||
);
|
||||
assert_eq!(
|
||||
s.match_target("11:22:33:44:55:66", None).as_deref(),
|
||||
Some("catch-all")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arch_selector_requires_known_arch() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
s.replace(BootRulesConfig {
|
||||
rules: vec![rule("", "uefi-arm64", "arm-image")],
|
||||
webhook_url: String::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
|
||||
.as_deref(),
|
||||
Some("arm-image")
|
||||
);
|
||||
// Wrong arch, or arch unknown to the chain → no match.
|
||||
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
|
||||
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_rules_are_skipped() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
let mut r = rule("", "", "x");
|
||||
r.enabled = false;
|
||||
s.replace(BootRulesConfig {
|
||||
rules: vec![r],
|
||||
webhook_url: String::new(),
|
||||
});
|
||||
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_round_trips_to_disk() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
s.replace(BootRulesConfig {
|
||||
rules: vec![rule("DC-A6-32", "", "pi-image")],
|
||||
webhook_url: " http://automation/boot ".into(),
|
||||
});
|
||||
drop(s);
|
||||
let s2 = BootRulesStore::load_or_default(dir.path());
|
||||
// Prefix was normalized on replace, webhook trimmed.
|
||||
assert_eq!(
|
||||
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
|
||||
Some("pi-image")
|
||||
);
|
||||
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_falls_back_to_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
|
||||
let s = BootRulesStore::load_or_default(dir.path());
|
||||
assert!(s.snapshot().rules.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! One-time(ish) access tokens for unattended answer files (v0.7.0).
|
||||
//!
|
||||
//! Why: answer files routinely embed credentials (local admin passwords,
|
||||
//! domain-join accounts, root hashes). Serving them to anyone who can
|
||||
//! GET `/unattended/<id>` is exactly the exposure that got WDS
|
||||
//! hands-free deployment disabled upstream (CVE-2026-0386 hardening
|
||||
//! guidance). OpenPXE generates every answer-file URL it injects into a
|
||||
//! boot chain, so it can scope each URL to the boot that requested it:
|
||||
//! when a boot script is rendered, a short-lived token is minted and
|
||||
//! appended; the serving endpoint requires it (or a logged-in operator
|
||||
//! session, so browser testing keeps working).
|
||||
//!
|
||||
//! Deliberately multi-use within the TTL rather than strictly one-shot:
|
||||
//! real installers fetch the same file more than once (initramfs +
|
||||
//! installer stage, cloud-init retries), and the token's job is to stop
|
||||
//! *unrelated* hosts from harvesting credentials, not to count fetches.
|
||||
//!
|
||||
//! In-memory only. A server restart invalidates outstanding tokens —
|
||||
//! acceptable because a restart also interrupts the ISO streaming an
|
||||
//! in-flight install depends on, and the next boot mints fresh ones.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Long enough to cover a slow OS install end-to-end (the answer file is
|
||||
/// fetched early, but cloud-init can re-read late), short enough that a
|
||||
/// leaked URL goes stale the same afternoon.
|
||||
const TOKEN_TTL: Duration = Duration::from_hours(4);
|
||||
|
||||
/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens
|
||||
/// are minted once per boot-script render, so this only matters under
|
||||
/// abuse, and serving must never become a memory-growth vector.
|
||||
const MAX_TOKENS: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Grant {
|
||||
file_id: String,
|
||||
issued: Instant,
|
||||
}
|
||||
|
||||
/// In-memory token table. Cheap to clone (`Arc`-shared).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BootTokens {
|
||||
inner: std::sync::Arc<Mutex<HashMap<String, Grant>>>,
|
||||
}
|
||||
|
||||
impl BootTokens {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Mint a token granting access to unattended file `file_id` for the
|
||||
/// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the
|
||||
/// generated URL.
|
||||
#[must_use]
|
||||
pub fn mint(&self, file_id: &str) -> String {
|
||||
self.mint_at(file_id, Instant::now())
|
||||
}
|
||||
|
||||
/// Is `token` a live grant for `file_id`?
|
||||
#[must_use]
|
||||
pub fn check(&self, token: &str, file_id: &str) -> bool {
|
||||
self.check_at(token, file_id, Instant::now())
|
||||
}
|
||||
|
||||
fn mint_at(&self, file_id: &str, now: Instant) -> String {
|
||||
let token = Uuid::new_v4().simple().to_string();
|
||||
let mut g = self.inner.lock();
|
||||
g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL);
|
||||
if g.len() >= MAX_TOKENS {
|
||||
if let Some(oldest) = g
|
||||
.iter()
|
||||
.min_by_key(|(_, gr)| gr.issued)
|
||||
.map(|(k, _)| k.clone())
|
||||
{
|
||||
g.remove(&oldest);
|
||||
}
|
||||
}
|
||||
g.insert(
|
||||
token.clone(),
|
||||
Grant {
|
||||
file_id: file_id.to_string(),
|
||||
issued: now,
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool {
|
||||
let g = self.inner.lock();
|
||||
g.get(token)
|
||||
.is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mint_then_check_round_trip() {
|
||||
let t = BootTokens::new();
|
||||
let tok = t.mint("ks-1");
|
||||
assert!(t.check(&tok, "ks-1"));
|
||||
// Multi-use within TTL: a second fetch still passes.
|
||||
assert!(t.check(&tok, "ks-1"));
|
||||
// Wrong file id never passes, even with a live token.
|
||||
assert!(!t.check(&tok, "ks-2"));
|
||||
// Unknown token never passes.
|
||||
assert!(!t.check("nope", "ks-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_expires_after_ttl() {
|
||||
let t = BootTokens::new();
|
||||
let now = Instant::now();
|
||||
let tok = t.mint_at("ks-1", now);
|
||||
let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap();
|
||||
assert!(t.check_at(&tok, "ks-1", now + just_before));
|
||||
assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_is_capped() {
|
||||
let t = BootTokens::new();
|
||||
let now = Instant::now();
|
||||
let first = t.mint_at("f", now);
|
||||
for i in 0..MAX_TOKENS {
|
||||
let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1));
|
||||
}
|
||||
// The oldest grant was evicted to stay within the cap.
|
||||
assert!(!t.check_at(&first, "f", now + Duration::from_secs(2)));
|
||||
assert!(t.inner.lock().len() <= MAX_TOKENS);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
pub mod arch;
|
||||
pub mod auth;
|
||||
pub mod boot_log;
|
||||
pub mod boot_rules;
|
||||
pub mod boot_tokens;
|
||||
pub mod branding;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
@@ -24,6 +26,8 @@ pub mod wol;
|
||||
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
pub use boot_log::{BootEvent, BootLog};
|
||||
pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore};
|
||||
pub use boot_tokens::BootTokens;
|
||||
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
|
||||
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
||||
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
||||
|
||||
Reference in New Issue
Block a user