Compare commits

..
1 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 7f25bb681c v0.6.3: russh 0.61 security bump (CVE batch) + bergshamra 0.5 + axum 0.8
Security-driven dependency release.

- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
  from our SFTP *client* path — unbounded/allocation-first packet
  parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
  CVE-2026-48107 in client auth. A malicious or compromised SFTP server
  an operator pointed us at could previously OOM the PXE server. Also
  drops mlock on non-secret buffers (~21% SSH throughput upstream) —
  directly in the remote-share ISO streaming path. ring backend kept;
  zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
  RustCrypto generation, pkcs8 0.11) is now met upstream, so the
  =0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
  secure-by-default for DSig (flags we already set explicitly) and
  fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
  the /api/docs listing; ConnectInfo optional extraction moves to the
  Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
  probe with HEAD before Range requests) and puts us back on the
  maintained line.

Validation: clippy clean, fmt clean, all 272 workspace tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:47:13 -04:00
4 changed files with 851 additions and 581 deletions
Generated
+787 -515
View File
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.6.2"
version = "0.6.3"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -33,7 +33,7 @@ dhcproto = "0.15"
socket2 = { version = "0.6", features = ["all"] }
bytes = "1.7"
axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
axum = { version = "0.8", features = ["macros", "multipart", "http2"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.9"
@@ -80,7 +80,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
bergshamra = "0.5"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
@@ -99,24 +99,19 @@ base64 = "0.22"
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that
# coexists with bergshamra-crypto (our SAML core). The RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of
# the same crates in the same semver bucket. russh 0.56+ pulls those
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC
# copies and breaks compilation. russh ≤0.55 stays on the previous stable
# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's
# *stable* deps and leaves the RC bucket untouched — verified to compile.
# 0.55 still has the merged `russh::keys` API (keys merged at 0.50).
# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts
# the stable RustCrypto generation; 0.56+ will not compile in this tree.
# CRITICAL #2 — history: this was pinned to =0.55.0 from v0.5.5 until
# v0.6.3 because bergshamra-crypto pinned release-candidate RustCrypto
# crates that conflicted with the stable generation russh 0.56+ pulls.
# bergshamra 0.5 (2026-06) moved to the stable generation (pkcs8 0.11),
# lifting the pin. v0.6.3 bumps to 0.61+, which also closes a batch of
# RUSTSEC advisories reachable from the SFTP *client* path (unbounded
# allocations in packet parsing — CVE-2026-48110/-46702/-46673 et al.)
# and drops mlock on non-secret buffers (~21% SSH throughput upstream).
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "=0.55.0", default-features = false, features = ["ring"] }
russh = { version = "0.61", default-features = false, features = ["ring"] }
russh-sftp = "2.3"
openpxe-core = { path = "crates/core" }
+49 -47
View File
@@ -71,7 +71,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
.route("/boot/{filename}", get(boot_sub))
// v0.5.2: unattended answer-file *serving* — public (like /iso),
// because the booting installer fetches these with no session.
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
@@ -80,12 +80,12 @@ pub fn build_router(state: AppState) -> Router {
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/:id", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
.route("/unattended/{id}", get(serve_unattended))
.route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed))
// Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw))
.route("/iso/:id/*path", get(iso_file))
.route("/ipxe/{name}", get(ipxe_binary))
.route("/iso/{filename}", get(iso_raw))
.route("/iso/{id}/{*path}", get(iso_file))
// Container health/readiness probes. `/healthz` is always 200 OK
// while the HTTP task is alive. `/readyz` additionally requires at
// least one bundled iPXE binary (without one, no client can PXE).
@@ -93,23 +93,23 @@ pub fn build_router(state: AppState) -> Router {
.route("/readyz", get(readyz))
// JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/:id", delete(api_delete_iso))
.route("/api/isos/{id}", delete(api_delete_iso))
.route("/api/uploads", post(api_upload_begin))
.route(
"/api/uploads/:upload_id",
"/api/uploads/{upload_id}",
put(api_upload_chunk).delete(api_upload_abort),
)
// Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears.
.route(
"/api/isos/:id/password",
"/api/isos/{id}/password",
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
)
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
// image appears under Linux/Windows Installers (default) or in
// the Tools submenu next to memtest / shell / NIC info.
.route(
"/api/isos/:id/category",
"/api/isos/{id}/category",
axum::routing::put(api_set_iso_category),
)
// v0.4.4: filesystem free-space telemetry for the ISO directory's
@@ -120,7 +120,7 @@ pub fn build_router(state: AppState) -> Router {
// logo). v0.5.2: split into three slots — `light` / `dark` /
// `client`. Multipart upload to POST; DELETE clears one slot.
.route(
"/api/branding/logo/:slot",
"/api/branding/logo/{slot}",
post(api_branding_upload).delete(api_branding_clear),
)
// v0.5.2: unattended-install answer-file management (gated).
@@ -130,7 +130,7 @@ pub fn build_router(state: AppState) -> Router {
"/api/unattended",
get(api_unattended_list).post(api_unattended_upload),
)
.route("/api/unattended/:id", delete(api_unattended_delete))
.route("/api/unattended/{id}", delete(api_unattended_delete))
// v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get
@@ -161,12 +161,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/settings", get(api_get_settings).put(api_put_settings))
.route("/api/queue", get(api_list_queue))
.route("/api/queue/join", get(api_queue_join))
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/poll/{entry_id}", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
// v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button.
.route("/api/queue/:entry_id/profile", put(api_queue_set_profile))
.route("/api/queue/:entry_id", delete(api_queue_release))
.route("/api/queue/{entry_id}/profile", put(api_queue_set_profile))
.route("/api/queue/{entry_id}", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client
@@ -177,8 +177,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/smb-shares",
get(api_smb_shares_list).post(api_smb_shares_add),
)
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
.route("/api/smb-shares/{id}", delete(api_smb_shares_remove))
.route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
@@ -186,8 +186,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/nfs-shares",
get(api_nfs_shares_list).post(api_nfs_shares_add),
)
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
.route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan))
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
// Parallel to SMB/NFS so the UI reuses the same form/error/hint
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
@@ -195,8 +195,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/sftp-shares",
get(api_sftp_shares_list).post(api_sftp_shares_add),
)
.route("/api/sftp-shares/:id", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan))
.route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -208,10 +208,10 @@ pub fn build_router(state: AppState) -> Router {
// Phase 5: per-MAC host bindings. Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
.route("/api/hosts/{mac}", delete(api_hosts_remove))
// 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))
.route("/api/hosts/{mac}/wol", post(api_hosts_wol))
// 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))
@@ -520,14 +520,15 @@ fn text_plain(body: String) -> Response {
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
Query(p): Query<BootMenuParams>,
) -> Response {
// `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we
// tolerate it by treating the peer as unknown rather than 500ing.
let peer_ip = peer.map(|c| c.0.ip());
// (axum 0.8: `Result<T, Rejection>` is the optional-extractor form.)
let peer_ip = peer.ok().map(|c| c.0.ip());
state
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
@@ -619,11 +620,12 @@ struct BootSubParams {
async fn boot_sub(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
let peer_ip = peer.map(|c| c.0.ip());
// axum 0.8: `Result<T, Rejection>` is the optional-extractor form.
let peer_ip = peer.ok().map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -1560,13 +1562,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
{"method": "POST", "path": "/api/isos",
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
{"method": "DELETE", "path": "/api/isos/:id",
{"method": "DELETE", "path": "/api/isos/{id}",
"summary": "Delete a local ISO and its sidecar metadata."},
{"method": "PUT", "path": "/api/isos/:id/password",
{"method": "PUT", "path": "/api/isos/{id}/password",
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
{"method": "DELETE", "path": "/api/isos/:id/password",
{"method": "DELETE", "path": "/api/isos/{id}/password",
"summary": "Clear an ISO's boot password."},
{"method": "PUT", "path": "/api/isos/:id/category",
{"method": "PUT", "path": "/api/isos/{id}/category",
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
],
},
@@ -1575,9 +1577,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"endpoints": [
{"method": "POST", "path": "/api/uploads",
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
{"method": "PUT", "path": "/api/uploads/:upload_id",
{"method": "PUT", "path": "/api/uploads/{upload_id}",
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
{"method": "DELETE", "path": "/api/uploads/:upload_id",
{"method": "DELETE", "path": "/api/uploads/{upload_id}",
"summary": "Abort a chunked upload session and remove the .partial file."},
],
},
@@ -1588,9 +1590,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/:id",
{"method": "DELETE", "path": "/api/smb-shares/{id}",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/:id/scan",
{"method": "POST", "path": "/api/smb-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1601,9 +1603,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured NFSv3 shares with connection state and iso counts."},
{"method": "POST", "path": "/api/nfs-shares",
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
{"method": "DELETE", "path": "/api/nfs-shares/:id",
{"method": "DELETE", "path": "/api/nfs-shares/{id}",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
{"method": "POST", "path": "/api/nfs-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1614,9 +1616,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
{"method": "POST", "path": "/api/sftp-shares",
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
{"method": "DELETE", "path": "/api/sftp-shares/:id",
{"method": "DELETE", "path": "/api/sftp-shares/{id}",
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
{"method": "POST", "path": "/api/sftp-shares/:id/scan",
{"method": "POST", "path": "/api/sftp-shares/{id}/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1636,9 +1638,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo/:slot",
{"method": "POST", "path": "/api/branding/logo/{slot}",
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
{"method": "DELETE", "path": "/api/branding/logo/:slot",
{"method": "DELETE", "path": "/api/branding/logo/{slot}",
"summary": "Remove the custom logo for a slot and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
@@ -1679,9 +1681,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended",
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
{"method": "DELETE", "path": "/api/unattended/:id",
{"method": "DELETE", "path": "/api/unattended/{id}",
"summary": "Delete an uploaded answer file."},
{"method": "GET", "path": "/unattended/:id",
{"method": "GET", "path": "/unattended/{id}",
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
],
},
@@ -1692,9 +1694,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List queue entries (waiting + assigned, with any deployment profile)."},
{"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "PUT", "path": "/api/queue/:entry_id/profile",
{"method": "PUT", "path": "/api/queue/{entry_id}/profile",
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/queue/:entry_id",
{"method": "DELETE", "path": "/api/queue/{entry_id}",
"summary": "Release a queue entry without assigning."},
],
},
@@ -1705,9 +1707,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
{"method": "DELETE", "path": "/api/hosts/{mac}",
"summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol",
{"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",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
+3 -2
View File
@@ -1489,9 +1489,10 @@ async fn api_docs_lists_known_endpoints() {
}
for needle in [
"/api/isos",
"/api/isos/:id/category",
// v0.6.3: docs use axum 0.8's `{param}` capture syntax.
"/api/isos/{id}/category",
"/api/storage/disk",
"/api/branding/logo/:slot",
"/api/branding/logo/{slot}",
"/api/unattended",
"/api/boot-log",
"/metrics",