Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f25bb681c |
Generated
+787
-515
File diff suppressed because it is too large
Load Diff
+12
-17
@@ -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
@@ -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)."},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user