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
+262
-34
@@ -212,6 +212,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
|
||||
// limited broadcast + the server's own subnet broadcast.
|
||||
.route("/api/hosts/{mac}/wol", post(api_hosts_wol))
|
||||
// v0.7.0: ordered boot rules (MAC prefix / arch → target) + the
|
||||
// boot-decision webhook. The UI saves the whole config at once
|
||||
// because rule order is significant.
|
||||
.route(
|
||||
"/api/boot-rules",
|
||||
get(api_boot_rules_get).put(api_boot_rules_put),
|
||||
)
|
||||
// Rolling "host log" of boot events: what image actually
|
||||
// started installing on what MAC/IP, and when. Persisted to disk.
|
||||
.route("/api/boot-log", get(api_boot_log))
|
||||
@@ -570,17 +577,105 @@ async fn boot_top_menu(
|
||||
// `?mac=` so the per-entry handler can record the boot into
|
||||
// the Host log without depending on iPXE substitution at
|
||||
// this stage.
|
||||
return text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo OpenPXE: per-MAC binding -> {target}\n\
|
||||
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
|
||||
));
|
||||
return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding"));
|
||||
}
|
||||
|
||||
// v0.7.0 step 2: ordered boot rules (MAC prefix / arch).
|
||||
let mac_norm = openpxe_core::normalize_mac(mac);
|
||||
if let Some(target) = state.boot_rules.match_target(&mac_norm, p.arch.as_deref()) {
|
||||
tracing::info!(
|
||||
target: "openpxe::http",
|
||||
mac = %mac_norm, target = %target, "boot rule matched"
|
||||
);
|
||||
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
|
||||
return text_plain(chain_script(base, &target, &mac_norm, "boot rule"));
|
||||
}
|
||||
|
||||
// v0.7.0 step 3: boot-decision webhook (fail-open — any error,
|
||||
// timeout, or non-200 falls through to the menu so a dead
|
||||
// automation endpoint can never block PXE for the network).
|
||||
if let Some(url) = state.boot_rules.webhook_url() {
|
||||
if let Some(target) = webhook_decide(&url, &mac_norm, p.arch.as_deref()).await {
|
||||
tracing::info!(
|
||||
target: "openpxe::http",
|
||||
mac = %mac_norm, target = %target, "boot webhook decided"
|
||||
);
|
||||
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
|
||||
return text_plain(chain_script(base, &target, &mac_norm, "boot webhook"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text_plain(render_menu(&isos, &settings, base))
|
||||
}
|
||||
|
||||
/// The short-circuit script all three decision sources (binding, rule,
|
||||
/// webhook) emit: chain to the target's boot script, falling back to the
|
||||
/// interactive menu so a stale target can't lock a client out.
|
||||
fn chain_script(base: &str, target: &str, mac: &str, source: &str) -> String {
|
||||
format!(
|
||||
"#!ipxe\n\
|
||||
echo OpenPXE: {source} -> {target}\n\
|
||||
chain {base}/boot/{target}.ipxe?mac={mac} || chain {base}/boot.ipxe\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Pre-record a decision-driven boot into the Host log, mirroring what
|
||||
/// the per-MAC binding path does: reserved `_xxx` targets are operator
|
||||
/// conveniences, not imaging events, so they're skipped.
|
||||
fn record_pre_boot(
|
||||
state: &AppState,
|
||||
isos: &[openpxe_iso_store::IsoMeta],
|
||||
mac: &str,
|
||||
peer_ip: Option<std::net::IpAddr>,
|
||||
target: &str,
|
||||
) {
|
||||
if target.starts_with('_') {
|
||||
return;
|
||||
}
|
||||
let title = lookup_entry_title(isos, target);
|
||||
state.boot_log.record(&BootEvent {
|
||||
timestamp: time::OffsetDateTime::now_utc(),
|
||||
mac: Some(mac.to_string()),
|
||||
ip: peer_ip,
|
||||
target_id: target.to_string(),
|
||||
target_title: title,
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the operator's boot-decision webhook for a target. `200` with
|
||||
/// `{"target": "<id>"}` chains to that target; anything else (including
|
||||
/// an empty target) means "no opinion". Two-second budget — a booting
|
||||
/// machine is sitting at a black screen while this runs.
|
||||
async fn webhook_decide(url: &str, mac: &str, arch: Option<&str>) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Decision {
|
||||
target: String,
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = match client
|
||||
.get(url)
|
||||
.query(&[("mac", mac), ("arch", arch.unwrap_or(""))])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::http", "boot webhook unreachable: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let d: Decision = resp.json().await.ok()?;
|
||||
let t = d.target.trim().to_string();
|
||||
(!t.is_empty()).then_some(t)
|
||||
}
|
||||
|
||||
/// Best-effort human title for a boot entry id — falls back to the id
|
||||
/// itself if the ISO has been deleted between record-time and now.
|
||||
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
|
||||
@@ -603,6 +698,11 @@ struct BootMenuParams {
|
||||
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
||||
/// fall back to the menu unconditionally.
|
||||
mac: Option<String>,
|
||||
/// v0.7.0: client architecture (`ClientArch::as_str()` form), baked
|
||||
/// literally into the chain URL by the DHCP proxy, which knows it
|
||||
/// from option 93. Lets boot rules select on architecture. Absent on
|
||||
/// chains rendered by older binaries — arch rules simply don't match.
|
||||
arch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -750,7 +850,13 @@ async fn boot_sub(
|
||||
.as_deref()
|
||||
.and_then(|fid| state.unattended.get(fid))
|
||||
.and_then(|meta| {
|
||||
build_unattended_args(base, &meta, Some(m), &p)
|
||||
build_unattended_args(
|
||||
base,
|
||||
&meta,
|
||||
Some(m),
|
||||
&p,
|
||||
&state.boot_tokens,
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -771,10 +877,19 @@ async fn boot_sub(
|
||||
|
||||
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
||||
|
||||
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
||||
async fn ipxe_binary(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
||||
}
|
||||
// v0.7.0: when the whole Secure Boot chain rides HTTP (native UEFI
|
||||
// HTTP Boot), GRUB resolves `$prefix` to this directory and fetches
|
||||
// its config from here — rendered live, same as the TFTP path.
|
||||
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
|
||||
return text_plain(crate::grub_script::render_grub_menu(
|
||||
&state.iso_store.list(),
|
||||
&state.public_base_url,
|
||||
));
|
||||
}
|
||||
let Some(data) = asset_slice(&name) else {
|
||||
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
||||
};
|
||||
@@ -1382,17 +1497,54 @@ struct UnattendedServeQuery {
|
||||
hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
ip: Option<String>,
|
||||
/// v0.7.0: short-lived access token minted into the generated URL.
|
||||
#[serde(default)]
|
||||
t: Option<String>,
|
||||
}
|
||||
|
||||
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
|
||||
/// v0.7.0: answer files routinely embed credentials, so once an admin
|
||||
/// account exists they're only served to (a) the boot that the URL was
|
||||
/// minted for — proven by the token OpenPXE put in that URL — or (b) a
|
||||
/// logged-in operator (browser testing). Pre-setup installs stay open,
|
||||
/// matching the auth middleware's bootstrap behavior.
|
||||
fn unattended_access_allowed(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
token: Option<&str>,
|
||||
file_id: &str,
|
||||
) -> bool {
|
||||
if !state.admin.is_configured() {
|
||||
return true;
|
||||
}
|
||||
if token.is_some_and(|t| state.boot_tokens.check(t, file_id)) {
|
||||
return true;
|
||||
}
|
||||
crate::auth::session_authenticated(state, headers)
|
||||
}
|
||||
|
||||
fn unattended_denied() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"answer files require the boot-scoped token OpenPXE mints into \
|
||||
generated URLs (or an operator session)",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
|
||||
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
|
||||
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
|
||||
/// fetching over HTTP) read it verbatim.
|
||||
/// fetching over HTTP) read it verbatim. Token-gated since v0.7.0 — see
|
||||
/// [`unattended_access_allowed`].
|
||||
async fn serve_unattended(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<UnattendedServeQuery>,
|
||||
) -> Response {
|
||||
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
|
||||
return unattended_denied();
|
||||
}
|
||||
let Ok(bytes) = state.unattended.read(&id).await else {
|
||||
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
|
||||
};
|
||||
@@ -1414,8 +1566,15 @@ async fn serve_unattended(
|
||||
async fn serve_unattended_seed(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let (mac, hostname, ip) = decode_seed_ctx(&ctx);
|
||||
let (mac, hostname, ip, token) = decode_seed_ctx(&ctx);
|
||||
// v0.7.0: the seed ctx carries the access token (the seedfrom URL
|
||||
// can't take a query string). Same gate as the flat answer-file
|
||||
// route — see `unattended_access_allowed`.
|
||||
if !unattended_access_allowed(&state, &headers, token.as_deref(), &id) {
|
||||
return unattended_denied();
|
||||
}
|
||||
match sub.as_str() {
|
||||
"user-data" => {
|
||||
let Ok(bytes) = state.unattended.read(&id).await else {
|
||||
@@ -1439,6 +1598,22 @@ async fn serve_unattended_seed(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Boot rules (v0.7.0) ───────────────────────────────────────────────────
|
||||
|
||||
async fn api_boot_rules_get(State(state): State<AppState>) -> Json<openpxe_core::BootRulesConfig> {
|
||||
Json(state.boot_rules.snapshot())
|
||||
}
|
||||
|
||||
async fn api_boot_rules_put(
|
||||
State(state): State<AppState>,
|
||||
Json(cfg): Json<openpxe_core::BootRulesConfig>,
|
||||
) -> StatusCode {
|
||||
let n = cfg.rules.len();
|
||||
state.boot_rules.replace(cfg);
|
||||
tracing::info!(target: "openpxe::http", rules = n, "boot rules replaced");
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
|
||||
/// a queued device's Profile. `None` when neither carries one.
|
||||
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
|
||||
@@ -1458,12 +1633,23 @@ fn build_unattended_args(
|
||||
meta: &UnattendedMeta,
|
||||
mac: Option<&str>,
|
||||
profile: &DeployProfile,
|
||||
tokens: &openpxe_core::BootTokens,
|
||||
) -> Option<String> {
|
||||
let base = base.trim_end_matches('/');
|
||||
let id = &meta.id;
|
||||
let host = profile.auto_hostname.as_deref();
|
||||
let ip = profile.auto_ip.as_deref();
|
||||
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
|
||||
// v0.7.0: every generated answer-file URL carries a fresh boot-scoped
|
||||
// token; the serving endpoint requires it. See `crate::auth` and
|
||||
// `openpxe_core::boot_tokens` for the threat model (CVE-2026-0386-
|
||||
// style credential harvesting from openly-served answer files).
|
||||
let token = tokens.mint(id);
|
||||
let query = build_query(&[
|
||||
("mac", mac),
|
||||
("hostname", host),
|
||||
("ip", ip),
|
||||
("t", Some(&token)),
|
||||
]);
|
||||
match meta.kind {
|
||||
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
|
||||
UnattendedKind::Preseed => {
|
||||
@@ -1475,7 +1661,7 @@ fn build_unattended_args(
|
||||
Some(s)
|
||||
}
|
||||
UnattendedKind::Autoinstall => {
|
||||
let ctx = encode_seed_ctx(mac, host, ip);
|
||||
let ctx = encode_seed_ctx(mac, host, ip, &token);
|
||||
Some(format!(
|
||||
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
|
||||
))
|
||||
@@ -1500,12 +1686,19 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
|
||||
|
||||
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
||||
|
||||
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
|
||||
/// the cloud-init seed directory. Empty values become empty fields.
|
||||
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
|
||||
/// Encode `(hostname, ip, mac, token)` into a single base64url path
|
||||
/// segment for the cloud-init seed directory. Empty values become empty
|
||||
/// fields. The access token rides in here (v0.7.0) because the
|
||||
/// `seedfrom` URL can't carry a query string.
|
||||
fn encode_seed_ctx(
|
||||
mac: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
token: &str,
|
||||
) -> String {
|
||||
use base64::Engine as _;
|
||||
let raw = format!(
|
||||
"{}\n{}\n{}",
|
||||
"{}\n{}\n{}\n{token}",
|
||||
hostname.unwrap_or(""),
|
||||
ip.unwrap_or(""),
|
||||
mac.unwrap_or("")
|
||||
@@ -1513,21 +1706,30 @@ fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>)
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
|
||||
/// or empty segment yields all-`None` so the seed still serves (just
|
||||
/// without per-host substitution).
|
||||
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||||
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`.
|
||||
/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
|
||||
/// ctx decodes with `token: None` (and the gate then rejects it once an
|
||||
/// admin exists — stale URLs are exactly what tokens invalidate).
|
||||
fn decode_seed_ctx(
|
||||
ctx: &str,
|
||||
) -> (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
use base64::Engine as _;
|
||||
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
|
||||
return (None, None, None);
|
||||
return (None, None, None, None);
|
||||
};
|
||||
let s = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let mut it = s.splitn(3, '\n');
|
||||
let mut it = s.splitn(4, '\n');
|
||||
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
|
||||
let hostname = clean(it.next());
|
||||
let ip = clean(it.next());
|
||||
let mac = clean(it.next());
|
||||
(mac, hostname, ip)
|
||||
let token = clean(it.next());
|
||||
(mac, hostname, ip, token)
|
||||
}
|
||||
|
||||
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
||||
@@ -1709,6 +1911,10 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
|
||||
{"method": "DELETE", "path": "/api/hosts/{mac}",
|
||||
"summary": "Remove a binding."},
|
||||
{"method": "GET", "path": "/api/boot-rules",
|
||||
"summary": "Boot rules + decision-webhook config (v0.7.0)."},
|
||||
{"method": "PUT", "path": "/api/boot-rules",
|
||||
"summary": "Replace the whole boot-rules config (rules are ordered)."},
|
||||
{"method": "POST", "path": "/api/hosts/{mac}/wol",
|
||||
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
|
||||
{"method": "GET", "path": "/api/boot-log",
|
||||
@@ -2942,11 +3148,13 @@ mod tests {
|
||||
auto_ip: Some("10.0.0.7".into()),
|
||||
unattended_file: Some("ks1".into()),
|
||||
};
|
||||
let tokens = openpxe_core::BootTokens::new();
|
||||
let a = build_unattended_args(
|
||||
"http://h",
|
||||
&meta(UnattendedKind::Kickstart),
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
&p,
|
||||
&tokens,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
|
||||
@@ -2954,6 +3162,9 @@ mod tests {
|
||||
assert!(a.contains("ip=10.0.0.7"), "{a}");
|
||||
// MAC colons are percent-encoded.
|
||||
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
|
||||
// v0.7.0: a live access token rides in the generated URL.
|
||||
let tok = a.rsplit("t=").next().unwrap();
|
||||
assert!(tokens.check(tok, "ks1"), "minted token must be live: {a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2962,8 +3173,15 @@ mod tests {
|
||||
auto_hostname: Some("deb1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let a =
|
||||
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
|
||||
let tokens = openpxe_core::BootTokens::new();
|
||||
let a = build_unattended_args(
|
||||
"http://h/",
|
||||
&meta(UnattendedKind::Preseed),
|
||||
None,
|
||||
&p,
|
||||
&tokens,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
|
||||
"{a}"
|
||||
@@ -2978,11 +3196,13 @@ mod tests {
|
||||
auto_ip: Some("10.1.1.5".into()),
|
||||
unattended_file: Some("ks1".into()),
|
||||
};
|
||||
let tokens = openpxe_core::BootTokens::new();
|
||||
let a = build_unattended_args(
|
||||
"http://h",
|
||||
&meta(UnattendedKind::Autoinstall),
|
||||
Some("aa:bb"),
|
||||
&p,
|
||||
&tokens,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -2992,10 +3212,12 @@ mod tests {
|
||||
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
|
||||
// The ctx segment round-trips back to the per-host values.
|
||||
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
|
||||
let (mac, host, ip) = decode_seed_ctx(ctx);
|
||||
let (mac, host, ip, token) = decode_seed_ctx(ctx);
|
||||
assert_eq!(mac.as_deref(), Some("aa:bb"));
|
||||
assert_eq!(host.as_deref(), Some("u1"));
|
||||
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
|
||||
// v0.7.0: the ctx carries a live access token for the file.
|
||||
assert!(tokens.check(token.as_deref().unwrap(), "ks1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3004,20 +3226,26 @@ mod tests {
|
||||
unattended_file: Some("ks1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
|
||||
.is_none()
|
||||
);
|
||||
let tokens = openpxe_core::BootTokens::new();
|
||||
assert!(build_unattended_args(
|
||||
"http://h",
|
||||
&meta(UnattendedKind::AnswerFile),
|
||||
None,
|
||||
&p,
|
||||
&tokens
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_ctx_empty_segment_decodes_to_none() {
|
||||
let ctx = encode_seed_ctx(None, None, None);
|
||||
let (m, h, i) = decode_seed_ctx(&ctx);
|
||||
let ctx = encode_seed_ctx(None, None, None, "tok");
|
||||
let (m, h, i, t) = decode_seed_ctx(&ctx);
|
||||
assert!(m.is_none() && h.is_none() && i.is_none());
|
||||
assert_eq!(t.as_deref(), Some("tok"));
|
||||
// Garbage decodes safely to all-None.
|
||||
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
|
||||
assert!(m2.is_none() && h2.is_none() && i2.is_none());
|
||||
let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
|
||||
assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user