Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.
## Naming changes
**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
`bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
`/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
brand string.
**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
`QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
`GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.
## Brand assets
The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
`rainbow-horizon` gradient from openpxe.com (sliding hue rotation
via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
louder version of the same disc — used for page-load transitions
and the imaging-progress widget. Adds a subtle scale pulse and a
white inner-glow so it has dimensionality on either theme.
## CSS rename
- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"
The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."
## Validation
- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
- `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
`/boot/_queue.ipxe`
- `/api/queue` returns `{count, entries}`
- `/metrics` emits `openpxe_queue_count` (renamed)
- `/assets/logo.svg` and `/assets/loader.svg` serve the new
rainbow brand SVGs
- `/api/status` reports version `0.3.0`
## Migration notes for operators on v0.2.0
- Container image path changed: pull
`gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
`gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
switch to `/api/queue/*`. The JSON envelope key is `entries`
instead of `gates`.
675 lines
25 KiB
Rust
675 lines
25 KiB
Rust
//! End-to-end HTTP integration test.
|
|
//!
|
|
//! Spins up the real axum router against a temp ISO store + settings store,
|
|
//! then walks an imaginary iPXE client through: dashboard status → upload
|
|
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
|
|
//! ISO. Also drives the Queued Deployment flow end-to-end: two clients join,
|
|
//! operator assigns, both polls return the chain script with retry fallback.
|
|
//!
|
|
//! This is the closest we can get to "real PXE client" without QEMU; the
|
|
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
|
|
//! every HTTP endpoint a real client touches is covered by a test.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{header, Request, StatusCode};
|
|
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
|
use openpxe_http_api::{build_router, AppState};
|
|
use openpxe_iso_store::{IsoStore, NfsManager};
|
|
use tempfile::tempdir;
|
|
use tower::ServiceExt;
|
|
|
|
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
|
|
/// introspection identifies it as Alpine.
|
|
fn fake_alpine_iso() -> Vec<u8> {
|
|
let mut buf = vec![0u8; 32 * 2048];
|
|
let off = 16 * 2048;
|
|
buf[off] = 0x01;
|
|
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
|
buf[off + 6] = 0x01;
|
|
let label = b"ALPINE-TEST".to_vec();
|
|
let mut padded = label.clone();
|
|
padded.resize(32, b' ');
|
|
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
|
|
let term = 17 * 2048;
|
|
buf[term] = 0xFF;
|
|
buf[term + 1..term + 6].copy_from_slice(b"CD001");
|
|
buf[term + 6] = 0x01;
|
|
buf
|
|
}
|
|
|
|
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
|
|
let boundary = "----PxeForgeTestBoundary1234";
|
|
let mut body = Vec::new();
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
format!(
|
|
"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
|
|
).as_bytes(),
|
|
);
|
|
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
|
|
body.extend_from_slice(bytes);
|
|
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
|
let ct = format!("multipart/form-data; boundary={boundary}");
|
|
(ct, body)
|
|
}
|
|
|
|
async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
|
|
let res = router
|
|
.clone()
|
|
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let status = res.status();
|
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
|
(status, body)
|
|
}
|
|
|
|
async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
|
|
let res = router
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(path)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body.to_owned()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = res.status();
|
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
|
(status, body)
|
|
}
|
|
|
|
async fn build_state() -> (AppState, tempfile::TempDir) {
|
|
let dir = tempdir().unwrap();
|
|
let iso_store = IsoStore::new(dir.path().join("isos"));
|
|
iso_store.ensure_dirs().await.unwrap();
|
|
let clients = ClientRegistry::new();
|
|
let gates = DeploymentQueue::new();
|
|
let settings = SettingsStore::load_or_default(dir.path());
|
|
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
|
iso_store.set_nfs_root(nfs.mount_root());
|
|
let log_bus = LogBus::new(64);
|
|
let hosts = HostBindings::load_or_default(dir.path());
|
|
let metrics = Metrics::new();
|
|
let state = AppState {
|
|
iso_store,
|
|
clients,
|
|
queue: gates,
|
|
settings,
|
|
hosts,
|
|
metrics,
|
|
smb: None,
|
|
nfs,
|
|
log_bus,
|
|
started_at: time::OffsetDateTime::now_utc(),
|
|
public_base_url: "http://127.0.0.1".into(),
|
|
nic_name: "lo".into(),
|
|
subnet_mask: "255.0.0.0".into(),
|
|
gateway: "127.0.0.1".into(),
|
|
};
|
|
(state, dir)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_and_ready_endpoints() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let (s, b) = get(&app, "/healthz").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(b, b"ok\n");
|
|
// /readyz should 503 when no iPXE binaries bundled at test time — this
|
|
// actually depends on whether CI has fetched them. Accept either.
|
|
let (s2, _) = get(&app, "/readyz").await;
|
|
assert!(
|
|
s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE,
|
|
"unexpected readyz status: {s2}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_introspects_and_generates_boot_entry() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state.clone());
|
|
|
|
let iso = fake_alpine_iso();
|
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/isos")
|
|
.header("content-type", ct)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::CREATED, "upload failed");
|
|
|
|
// Confirm the ISO shows up in the menu.
|
|
let (_, menu) = get(&app, "/boot.ipxe").await;
|
|
let menu = String::from_utf8(menu).unwrap();
|
|
assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}");
|
|
|
|
let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
|
|
let linux = String::from_utf8(linux).unwrap();
|
|
assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}");
|
|
assert!(linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
|
|
"size label missing in {linux}");
|
|
|
|
// Per-entry boot script should include kernel + initrd URLs + boot.
|
|
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
|
|
let entry = String::from_utf8(entry).unwrap();
|
|
assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts"));
|
|
assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts"));
|
|
assert!(entry.contains("boot || goto failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn iso_range_request_slices_correctly() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state.clone());
|
|
let iso = fake_alpine_iso();
|
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST").uri("/api/isos")
|
|
.header("content-type", ct)
|
|
.body(Body::from(body)).unwrap()).await.unwrap();
|
|
|
|
// Range bytes=0x8000-0x8005 should return the PVD signature byte.
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/iso/fake-alpine.iso")
|
|
.header(header::RANGE, "bytes=32768-32773")
|
|
.body(Body::empty()).unwrap())
|
|
.await.unwrap();
|
|
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
|
|
let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
|
assert_eq!(slice[0], 0x01); // PVD type
|
|
assert_eq!(&slice[1..6], b"CD001");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queued_deployment_full_flow() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state.clone());
|
|
|
|
// Upload an ISO so the target exists.
|
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
|
app.clone()
|
|
.oneshot(Request::builder().method("POST").uri("/api/isos")
|
|
.header("content-type", ct).body(Body::from(body)).unwrap())
|
|
.await.unwrap();
|
|
|
|
// Two clients join.
|
|
let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await;
|
|
let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await;
|
|
let s1 = String::from_utf8(join1).unwrap();
|
|
let s2 = String::from_utf8(join2).unwrap();
|
|
assert!(s1.contains("Gate Position 1"));
|
|
assert!(s2.contains("Gate Position 2"));
|
|
|
|
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
|
|
.unwrap().to_string();
|
|
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
|
|
.unwrap().to_string();
|
|
|
|
// Kick off a long-poll for client 1 in the background. Then assign.
|
|
let app2 = app.clone();
|
|
let poll_future = tokio::spawn(async move {
|
|
let uri = format!("/api/queue/poll/{gate1_id}");
|
|
get(&app2, &uri).await
|
|
});
|
|
|
|
// Give the poll a moment to register its notify subscription.
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
// Operator assigns.
|
|
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#);
|
|
let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let assign_json = String::from_utf8(b).unwrap();
|
|
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
|
|
|
|
// Now assign to gate 1 too so the background poll wakes.
|
|
let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
|
|
post_json(&app, "/api/queue/assign", body).await;
|
|
|
|
let (poll_status, poll_body) = poll_future.await.unwrap();
|
|
assert_eq!(poll_status, StatusCode::OK);
|
|
let poll_s = String::from_utf8(poll_body).unwrap();
|
|
assert!(
|
|
poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"),
|
|
"poll response should chain the boot script:\n{poll_s}"
|
|
);
|
|
// Retry-on-error fallback must be present.
|
|
assert!(poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
|
|
"retry fallback missing");
|
|
|
|
// Bad target must be rejected.
|
|
let (_, bad) = post_json(&app, "/api/queue/assign",
|
|
r#"{"target":"does-not-exist","entry_ids":[]}"#).await;
|
|
let bad_s = String::from_utf8(bad).unwrap();
|
|
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settings_put_persists_across_reads() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let body = serde_json::json!({
|
|
"boot_menu_timeout_secs": 42,
|
|
"timeout_action": "local_hdd",
|
|
"windows_enabled": false,
|
|
"smb_host_override": "",
|
|
"extra_kernel_args": "console=ttyS0",
|
|
"default_local_hdd": true,
|
|
"gate_wait_max_secs": 0
|
|
}).to_string();
|
|
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/api/settings")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body))
|
|
.unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
|
|
|
let (_, g) = get(&app, "/api/settings").await;
|
|
let got: serde_json::Value = serde_json::from_slice(&g).unwrap();
|
|
assert_eq!(got["boot_menu_timeout_secs"], 42);
|
|
assert_eq!(got["timeout_action"], "local_hdd");
|
|
assert_eq!(got["extra_kernel_args"], "console=ttyS0");
|
|
|
|
// And the menu should now use the new timeout.
|
|
let (_, menu) = get(&app, "/boot.ipxe").await;
|
|
let menu = String::from_utf8(menu).unwrap();
|
|
assert!(menu.contains("--timeout 42000"),
|
|
"menu should reflect 42s timeout:\n{menu}");
|
|
assert!(menu.contains("--default local"),
|
|
"menu should default to local:\n{menu}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reboot_and_firmware_exit_in_tools_menu() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
|
|
let tools = String::from_utf8(tools).unwrap();
|
|
assert!(tools.contains("Reboot Computer"),
|
|
"tools menu missing Reboot item:\n{tools}");
|
|
assert!(tools.contains("Exit and continue BIOS boot"),
|
|
"tools menu missing firmware-exit item:\n{tools}");
|
|
assert!(tools.contains("&& reboot"),
|
|
"reboot command not wired:\n{tools}");
|
|
assert!(tools.contains("&& exit 0"),
|
|
"firmware exit command not wired:\n{tools}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ui_assets_served_offline() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
|
|
for (path, ct) in [
|
|
("/", "text/html"),
|
|
("/assets/app.js", "application/javascript"),
|
|
("/assets/app.css", "text/css"),
|
|
("/assets/logo.svg", "image/svg+xml"),
|
|
("/assets/loader.svg", "image/svg+xml"),
|
|
] {
|
|
let res = app
|
|
.clone()
|
|
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
|
.await.unwrap();
|
|
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
|
|
let got = res.headers()
|
|
.get(header::CONTENT_TYPE).unwrap()
|
|
.to_str().unwrap();
|
|
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_external_urls_in_generated_ipxe() {
|
|
// Sanity check that nothing we serve points off-server.
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe",
|
|
"/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] {
|
|
let (_, body) = get(&app, path).await;
|
|
let s = String::from_utf8(body).unwrap();
|
|
// The only URLs we should emit are relative to our own public_base_url.
|
|
for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] {
|
|
assert!(!s.contains(url), "{path} references external host {url}:\n{s}");
|
|
}
|
|
// Confirm URLs are all ours.
|
|
for line in s.lines() {
|
|
if let Some(idx) = line.find("http://") {
|
|
let rest = &line[idx..];
|
|
assert!(rest.starts_with("http://127.0.0.1"),
|
|
"{path} references non-public-base URL: {line}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Phase 4 integration tests ────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn nfs_add_with_bad_export_is_rejected() {
|
|
// Validation must happen before we shell out to /bin/mount —
|
|
// otherwise the operator sees opaque kernel errors instead of a
|
|
// clear "your export must start with /" hint.
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let (s, b) = post_json(
|
|
&app,
|
|
"/api/nfs",
|
|
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
|
let msg = String::from_utf8_lossy(&b);
|
|
assert!(msg.contains("export"), "expected validation hint, got: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nfs_list_starts_empty() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let (s, b) = get(&app, "/api/nfs").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_help_and_status_round_trip() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
|
|
// Empty command -> help banner.
|
|
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
assert!(v["output"].as_str().unwrap().contains("OpenPXE terminal"));
|
|
|
|
// status -> contains the version banner.
|
|
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
let out = v["output"].as_str().unwrap();
|
|
assert!(out.starts_with("OpenPXE"), "unexpected status output: {out}");
|
|
assert!(out.contains("isos:"), "status missing iso line: {out}");
|
|
|
|
// Unknown command -> ok=false plus help hint.
|
|
let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
assert_eq!(v["ok"], false);
|
|
assert!(v["output"].as_str().unwrap().contains("unknown command"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn log_recent_returns_buffered_lines() {
|
|
// The terminal command we issued seeds the log bus, so a follow-up
|
|
// /api/log/recent must surface those lines as JSON.
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
|
|
let (s, b) = get(&app, "/api/log/recent").await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
let lines = v["lines"].as_array().expect("lines array");
|
|
assert!(!lines.is_empty(), "log buffer should have at least one line");
|
|
// Every entry should have the canonical timestamp/level/target/message.
|
|
for l in lines {
|
|
for k in ["timestamp", "level", "target", "message"] {
|
|
assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
|
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
|
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
|
let mut buf = vec![0u8; 32 * 2048];
|
|
let off = 16 * 2048;
|
|
buf[off] = 0x01;
|
|
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
|
buf[off + 6] = 0x01;
|
|
let label = b"WIN11_X64".to_vec();
|
|
let mut padded = label.clone();
|
|
padded.resize(32, b' ');
|
|
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
|
|
// Sprinkle the sources/boot.wim sentinel where the introspection
|
|
// scanner will find it (anywhere in the first 64 MB).
|
|
let sentinel = b"SOURCES\\BOOT.WIM";
|
|
buf.extend_from_slice(sentinel);
|
|
let term = 17 * 2048;
|
|
buf[term] = 0xFF;
|
|
buf[term + 1..term + 6].copy_from_slice(b"CD001");
|
|
buf[term + 6] = 0x01;
|
|
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
|
|
// Need windows_enabled for the Windows path to render in the menu.
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/api/settings")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
|
|
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
|
|
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// wimboot binary is bundled in this repo so windows_enabled=true should
|
|
// not be rejected; we leave it false to keep the upload path agnostic.
|
|
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
|
|
|
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/isos")
|
|
.header("content-type", ct)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::CREATED);
|
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
|
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(meta["introspection"]["family"], "windows_pe");
|
|
assert!(
|
|
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
|
|
"introspection should detect sources/boot.wim sentinel"
|
|
);
|
|
|
|
// The boot entry should be a wimboot kind with the canonical 5-file
|
|
// chain documented in the LinusTechTips iPXE-Windows guide.
|
|
let entry = &meta["boot_entries"][0];
|
|
assert_eq!(entry["kind"]["kind"], "wimboot");
|
|
let files = entry["kind"]["files"].as_array().unwrap();
|
|
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
|
|
assert!(names.contains(&"bootmgr"));
|
|
assert!(names.contains(&"bootmgr.efi"));
|
|
assert!(names.contains(&"bcd"));
|
|
assert!(names.contains(&"boot.sdi"));
|
|
assert!(names.contains(&"boot.wim"));
|
|
|
|
// Render the entry script and verify:
|
|
// 1. It uses wimboot
|
|
// 2. All 5 files are referenced via `initrd --name`
|
|
// 3. NO trust-store / driver / testsigning operations slip in
|
|
let entry_id = entry["id"].as_str().unwrap();
|
|
let url = format!("/boot/{entry_id}.ipxe");
|
|
let (s, body) = get(&app, &url).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let script = String::from_utf8(body).unwrap();
|
|
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
|
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
|
|
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
|
assert!(
|
|
script.contains(&format!("initrd --name {tag}")),
|
|
"missing `initrd --name {tag}` line:\n{script}"
|
|
);
|
|
}
|
|
// Hard guarantees we never want to see in any client-facing script.
|
|
let lower = script.to_lowercase();
|
|
for forbidden in [
|
|
"bcdedit", "testsigning", "certutil", "test-signed",
|
|
"httpdisk", "/set testsigning",
|
|
] {
|
|
assert!(
|
|
!lower.contains(forbidden),
|
|
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_binding_short_circuits_boot_menu() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state.clone());
|
|
|
|
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
|
|
// built-in target so the upsert validator accepts it without
|
|
// requiring a real ISO.
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/hosts")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::CREATED);
|
|
|
|
// Hit /boot.ipxe with the bound MAC and assert we get the
|
|
// short-circuit chain instead of the menu.
|
|
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
|
|
assert_eq!(s1, StatusCode::OK);
|
|
let body1 = String::from_utf8(b1).unwrap();
|
|
assert!(
|
|
body1.contains("per-MAC binding"),
|
|
"expected MAC short-circuit, got:\n{body1}"
|
|
);
|
|
assert!(body1.contains("/boot/_local.ipxe"));
|
|
|
|
// And a different MAC still gets the menu.
|
|
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
|
|
assert_eq!(s2, StatusCode::OK);
|
|
let body2 = String::from_utf8(b2).unwrap();
|
|
assert!(
|
|
body2.contains("menu") || body2.contains("Default"),
|
|
"expected interactive menu, got:\n{body2}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metrics_endpoint_emits_prometheus_format() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
// Drive a couple of paths so counters move off zero.
|
|
let _ = get(&app, "/api/status").await;
|
|
let _ = get(&app, "/boot.ipxe").await;
|
|
|
|
let res = app
|
|
.clone()
|
|
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::OK);
|
|
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
|
|
assert!(
|
|
ct.starts_with("text/plain"),
|
|
"wrong content-type: {ct}"
|
|
);
|
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
|
let body = String::from_utf8(body.to_vec()).unwrap();
|
|
// Spot-check the must-have metric families.
|
|
for name in [
|
|
"openpxe_dhcp_replies_total",
|
|
"openpxe_tftp_transfers_total",
|
|
"openpxe_http_requests_total",
|
|
"openpxe_iso_count",
|
|
"openpxe_uptime_seconds",
|
|
"openpxe_build_info",
|
|
] {
|
|
assert!(body.contains(name), "missing metric {name} in:\n{body}");
|
|
}
|
|
// Each name appears exactly once as a `# TYPE` declaration.
|
|
for name in [
|
|
"openpxe_dhcp_replies_total",
|
|
"openpxe_iso_count",
|
|
] {
|
|
let count = body.matches(&format!("# TYPE {name}")).count();
|
|
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn network_endpoint_exposes_dns_round_trip() {
|
|
let (state, _dir) = build_state().await;
|
|
let app = build_router(state);
|
|
|
|
// GET starts blank.
|
|
let (_, b) = get(&app, "/api/network").await;
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
assert_eq!(v["dns_server"], "");
|
|
assert_eq!(v["nic_name"], "lo");
|
|
|
|
// PUT updates only the DNS field.
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PUT")
|
|
.uri("/api/network")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
|
|
|
let (_, b) = get(&app, "/api/network").await;
|
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
|
assert_eq!(v["dns_server"], "10.0.0.1");
|
|
}
|