v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
Closes the v0.4.x chapter — NFS works end to end. Five additions: ## Wake-on-LAN (Hosts → Bound hosts) - New core::wol module: parse any MAC form, build the 102-byte magic packet, broadcast it. No special capability needed (ephemeral source port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255) AND the server's own subnet broadcast (computed from advertised IP + detected mask) so it reaches the right VLAN. - POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise) so it's not an open packet sprayer. - Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓ state. ## Webhook notifications (Advanced tab) - core::notify: NotifyConfig + NotifyStore (notify.json), one provider at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP. SMTP password is persisted but redacted on GET behind a __keep__ sentinel the UI round-trips so the secret never leaves the box. - http-api::notify: delivery — reqwest POST for chat (provider-shaped bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext). 10s timeout; every send is best-effort. - GET/PUT /api/notify, POST /api/notify/test. - Fired fire-and-forget on the canonical "machine is imaging" boot event and on WoL — never blocks the boot path. ## UI: Advanced tab - New nav item. Holds the webhook config card and the API reference block (relocated from the bottom of Settings). ## UI: login/setup logo (FleetDM treatment) - /api/me now returns has_custom_logo + logo_rev (public bootstrap). The login, setup, and connection-error cards render the uploaded logo full-width with the "OpenPXE" wordmark dropped — matching the sidebar. ## About: update check + licenses - "Check for updates" button → GET /api/updates/check queries the Gitea releases API (derived from CARGO_PKG_REPOSITORY) and compares to the running version. Strictly on-demand — no background polling, keeps the air-gapped promise. - License card documents the MIT OR Apache-2.0 dual license with links, plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools). Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both rustls so the static musl binary stays OpenSSL-free. Tests: 179 passing (+notify round-trip/redaction, webhook validation, WoL-unbound-404, WoL packet loopback, version-compare). clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb41288c3
commit
fc99973ac3
Generated
+477
-23
@@ -248,12 +248,28 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -369,8 +385,8 @@ dependencies = [
|
|||||||
"dhcproto-macros",
|
"dhcproto-macros",
|
||||||
"hex",
|
"hex",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"trust-dns-proto",
|
"trust-dns-proto",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -402,6 +418,22 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -454,6 +486,12 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -590,8 +628,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -602,7 +656,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -682,6 +736,17 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hostname"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -752,6 +817,23 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -760,13 +842,21 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"socket2 0.6.3",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -944,6 +1034,16 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iri-string"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -962,6 +1062,8 @@ version = "0.3.95"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
@@ -978,6 +1080,34 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"hostname",
|
||||||
|
"httpdate",
|
||||||
|
"idna 1.1.0",
|
||||||
|
"mime",
|
||||||
|
"nom",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"rustls",
|
||||||
|
"socket2 0.6.3",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"url",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.185"
|
version = "0.2.185"
|
||||||
@@ -1011,6 +1141,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1133,6 +1269,15 @@ dependencies = [
|
|||||||
"nfs3_macros",
|
"nfs3_macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1171,7 +1316,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1193,7 +1338,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1201,7 +1346,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -1212,21 +1357,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"dhcproto",
|
"dhcproto",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1234,6 +1379,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"hyper",
|
"hyper",
|
||||||
"image",
|
"image",
|
||||||
|
"lettre",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
@@ -1241,10 +1387,11 @@ dependencies = [
|
|||||||
"openpxe-iso-store",
|
"openpxe-iso-store",
|
||||||
"openpxe-webui",
|
"openpxe-webui",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -1257,17 +1404,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1284,7 +1431,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -1294,21 +1441,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"openpxe-ipxe-assets",
|
"openpxe-ipxe-assets",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
@@ -1413,6 +1560,61 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2 0.6.3",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2 0.6.3",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1422,6 +1624,18 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1435,8 +1649,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1446,7 +1670,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1458,6 +1692,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -1484,6 +1727,61 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-embed"
|
name = "rust-embed"
|
||||||
version = "8.11.0"
|
version = "8.11.0"
|
||||||
@@ -1519,6 +1817,12 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1532,6 +1836,42 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.40"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -1660,6 +2000,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -1759,6 +2105,9 @@ name = "sync_wrapper"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
@@ -1790,7 +2139,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1804,6 +2162,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -1897,6 +2266,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -1994,12 +2373,14 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"http-range-header",
|
"http-range-header",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
|
"iri-string",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2108,14 +2489,20 @@ dependencies = [
|
|||||||
"idna 0.2.3",
|
"idna 0.2.3",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@@ -2155,6 +2542,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -2213,6 +2606,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -2250,6 +2652,16 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.68"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.118"
|
version = "0.2.118"
|
||||||
@@ -2304,6 +2716,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
@@ -2316,6 +2741,35 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.95"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
+7
-2
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.69"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
|
||||||
hyper = "1.4"
|
hyper = "1.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
|
|
||||||
@@ -66,6 +66,11 @@ rust-embed = { version = "8.5", features = ["include-exclude"] }
|
|||||||
nfs3_client = { version = "0.9", features = ["tokio"] }
|
nfs3_client = { version = "0.9", features = ["tokio"] }
|
||||||
nfs3_types = "0.5"
|
nfs3_types = "0.5"
|
||||||
|
|
||||||
|
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
|
||||||
|
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
|
||||||
|
# to match reqwest and stay musl-static-friendly — no OpenSSL.
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||||
|
|
||||||
openpxe-core = { path = "crates/core" }
|
openpxe-core = { path = "crates/core" }
|
||||||
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
|
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
|
||||||
openpxe-tftp = { path = "crates/tftp" }
|
openpxe-tftp = { path = "crates/tftp" }
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ pub mod error;
|
|||||||
pub mod host_bindings;
|
pub mod host_bindings;
|
||||||
pub mod log_bus;
|
pub mod log_bus;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod notify;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod sso;
|
pub mod sso;
|
||||||
|
pub mod wol;
|
||||||
|
|
||||||
pub use arch::{ClientArch, FirmwareClass};
|
pub use arch::{ClientArch, FirmwareClass};
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
@@ -27,5 +29,6 @@ pub use error::{Error, Result};
|
|||||||
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
||||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||||
pub use metrics::{HttpRoute, Metrics};
|
pub use metrics::{HttpRoute, Metrics};
|
||||||
|
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
|
||||||
pub use queue::{DeploymentQueue, QueueEntry};
|
pub use queue::{DeploymentQueue, QueueEntry};
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
//! Webhook / email notification configuration.
|
||||||
|
//!
|
||||||
|
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
|
||||||
|
//! something noteworthy happens (a machine PXE-booted an image, a
|
||||||
|
//! deployment was assigned, a WoL was sent). One active provider at a
|
||||||
|
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
|
||||||
|
//! paste the incoming-webhook URL, done.
|
||||||
|
//!
|
||||||
|
//! This module owns only the *configuration* (validation + persistence
|
||||||
|
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
|
||||||
|
//! chat providers, SMTP for email — lives in the http-api crate, which
|
||||||
|
//! already carries an HTTP client and the SMTP dependency. Keeping the
|
||||||
|
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
|
||||||
|
//! stay pure config stores.
|
||||||
|
//!
|
||||||
|
//! Secrets note: the SMTP password is persisted in `notify.json`
|
||||||
|
//! alongside the rest of the config (0644 like the other state files).
|
||||||
|
//! It is never echoed back through the API — the snapshot used for the
|
||||||
|
//! GET response blanks it (see `Self::redacted`).
|
||||||
|
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
const MAX_URL_LEN: usize = 2048;
|
||||||
|
const MAX_FIELD_LEN: usize = 512;
|
||||||
|
|
||||||
|
/// Which notification transport is active.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum NotifyKind {
|
||||||
|
/// Slack incoming webhook (`{ "text": ... }`).
|
||||||
|
#[default]
|
||||||
|
Slack,
|
||||||
|
/// Discord webhook (`{ "content": ... }`).
|
||||||
|
Discord,
|
||||||
|
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
|
||||||
|
Teams,
|
||||||
|
/// Email via SMTP.
|
||||||
|
Smtp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotifyKind {
|
||||||
|
/// True when this kind drives a chat webhook (POST a JSON body to a
|
||||||
|
/// single URL) rather than SMTP.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_webhook(self) -> bool {
|
||||||
|
matches!(self, Self::Slack | Self::Discord | Self::Teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operator-configurable notification settings. Single provider active
|
||||||
|
/// at a time; the inactive fields are kept so switching providers
|
||||||
|
/// doesn't wipe the other one's values.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct NotifyConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: NotifyKind,
|
||||||
|
/// Incoming-webhook URL for Slack / Discord / Teams.
|
||||||
|
#[serde(default)]
|
||||||
|
pub webhook_url: String,
|
||||||
|
|
||||||
|
// ── SMTP fields (used when kind == Smtp) ──
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_host: String,
|
||||||
|
#[serde(default = "default_smtp_port")]
|
||||||
|
pub smtp_port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_password: String,
|
||||||
|
/// `From:` address. Falls back to `smtp_username` when blank.
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_from: String,
|
||||||
|
/// `To:` address (single recipient — keep it simple).
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_to: String,
|
||||||
|
/// Use implicit TLS (port 465). When false we use STARTTLS on the
|
||||||
|
/// configured port (587 typical). Either way the connection is
|
||||||
|
/// encrypted — we never offer plaintext SMTP.
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_implicit_tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_smtp_port() -> u16 {
|
||||||
|
587
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotifyConfig {
|
||||||
|
/// True when enabled and the active provider has the fields it
|
||||||
|
/// needs to actually send.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_usable(&self) -> bool {
|
||||||
|
if !self.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.kind.is_webhook() {
|
||||||
|
!self.webhook_url.trim().is_empty()
|
||||||
|
} else {
|
||||||
|
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy safe to return over the API: the SMTP password is blanked
|
||||||
|
/// (replaced with a non-empty sentinel only when one is set, so the
|
||||||
|
/// UI can show "configured" without leaking it).
|
||||||
|
#[must_use]
|
||||||
|
pub fn redacted(&self) -> NotifyConfig {
|
||||||
|
let mut c = self.clone();
|
||||||
|
if !c.smtp_password.is_empty() {
|
||||||
|
c.smtp_password = SECRET_SENTINEL.to_string();
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned by the API in place of a stored password. When the UI PUTs
|
||||||
|
/// this value back unchanged we keep the existing password rather than
|
||||||
|
/// overwriting it with the sentinel.
|
||||||
|
pub const SECRET_SENTINEL: &str = "__keep__";
|
||||||
|
|
||||||
|
/// In-memory + on-disk notification config registry.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NotifyStore {
|
||||||
|
path: Arc<PathBuf>,
|
||||||
|
inner: Arc<RwLock<NotifyConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotifyStore {
|
||||||
|
#[must_use]
|
||||||
|
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
||||||
|
let path = work_dir.join("notify.json");
|
||||||
|
let cfg = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::notify",
|
||||||
|
"notify.json unreadable ({e}); starting with defaults"
|
||||||
|
);
|
||||||
|
NotifyConfig::default()
|
||||||
|
}),
|
||||||
|
Err(_) => NotifyConfig::default(),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
path: Arc::new(path),
|
||||||
|
inner: Arc::new(RwLock::new(cfg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> NotifyConfig {
|
||||||
|
self.inner.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
|
||||||
|
/// is treated as "keep the existing password" so the UI never has to
|
||||||
|
/// round-trip the real secret.
|
||||||
|
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
|
||||||
|
incoming.webhook_url = incoming.webhook_url.trim().to_string();
|
||||||
|
incoming.smtp_host = incoming.smtp_host.trim().to_string();
|
||||||
|
incoming.smtp_username = incoming.smtp_username.trim().to_string();
|
||||||
|
incoming.smtp_from = incoming.smtp_from.trim().to_string();
|
||||||
|
incoming.smtp_to = incoming.smtp_to.trim().to_string();
|
||||||
|
|
||||||
|
// Preserve the stored password when the UI sends the sentinel.
|
||||||
|
if incoming.smtp_password == SECRET_SENTINEL {
|
||||||
|
incoming
|
||||||
|
.smtp_password
|
||||||
|
.clone_from(&self.inner.read().smtp_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length caps.
|
||||||
|
if incoming.webhook_url.len() > MAX_URL_LEN {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"webhook URL exceeds {MAX_URL_LEN}-char cap"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (name, v) in [
|
||||||
|
("smtp_host", &incoming.smtp_host),
|
||||||
|
("smtp_username", &incoming.smtp_username),
|
||||||
|
("smtp_from", &incoming.smtp_from),
|
||||||
|
("smtp_to", &incoming.smtp_to),
|
||||||
|
] {
|
||||||
|
if v.len() > MAX_FIELD_LEN {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"{name} exceeds {MAX_FIELD_LEN}-char cap"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the active provider only when enabling.
|
||||||
|
if incoming.enabled {
|
||||||
|
if incoming.kind.is_webhook() {
|
||||||
|
if incoming.webhook_url.is_empty() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"a webhook URL is required to enable chat notifications".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !incoming.webhook_url.starts_with("https://")
|
||||||
|
&& !incoming.webhook_url.starts_with("http://")
|
||||||
|
{
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"webhook URL must start with http:// or https://".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if incoming.smtp_host.is_empty() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"SMTP host is required to enable email notifications".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if incoming.smtp_to.is_empty() {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"a recipient (To) is required to enable email notifications".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if incoming.smtp_port == 0 {
|
||||||
|
return Err(Error::Invalid("SMTP port must be non-zero".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut g = self.inner.write();
|
||||||
|
*g = incoming.clone();
|
||||||
|
}
|
||||||
|
self.persist();
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::notify",
|
||||||
|
enabled = incoming.enabled, kind = ?incoming.kind,
|
||||||
|
"notification configuration updated"
|
||||||
|
);
|
||||||
|
Ok(incoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
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::notify", "serialize notify.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::notify", "write notify.json tmp: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||||
|
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_disabled_not_usable() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = NotifyStore::load_or_default(dir.path());
|
||||||
|
assert!(!s.snapshot().enabled);
|
||||||
|
assert!(!s.snapshot().is_usable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slack_requires_url_when_enabled() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = NotifyStore::load_or_default(dir.path());
|
||||||
|
let r = s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Slack,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
|
s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Slack,
|
||||||
|
webhook_url: "https://hooks.slack.com/services/XXX".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(s.snapshot().is_usable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_requires_host_and_recipient() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = NotifyStore::load_or_default(dir.path());
|
||||||
|
let r = s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Smtp,
|
||||||
|
smtp_host: "smtp.example.com".into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
|
||||||
|
s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Smtp,
|
||||||
|
smtp_host: "smtp.example.com".into(),
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_to: "[email protected]".into(),
|
||||||
|
smtp_from: "[email protected]".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(s.snapshot().is_usable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_sentinel_preserves_stored_secret() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = NotifyStore::load_or_default(dir.path());
|
||||||
|
s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Smtp,
|
||||||
|
smtp_host: "smtp.example.com".into(),
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_to: "[email protected]".into(),
|
||||||
|
smtp_password: "s3cret".into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
// Redacted snapshot hides the password behind the sentinel.
|
||||||
|
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
|
||||||
|
// PUTting the sentinel back keeps the real password.
|
||||||
|
s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Smtp,
|
||||||
|
smtp_host: "smtp.example.com".into(),
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_to: "[email protected]".into(),
|
||||||
|
smtp_password: SECRET_SENTINEL.into(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.snapshot().smtp_password, "s3cret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webhook_url_scheme_enforced() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let s = NotifyStore::load_or_default(dir.path());
|
||||||
|
let r = s.replace(NotifyConfig {
|
||||||
|
enabled: true,
|
||||||
|
kind: NotifyKind::Discord,
|
||||||
|
webhook_url: "ftp://example.com/hook".into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! Wake-on-LAN.
|
||||||
|
//!
|
||||||
|
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
|
||||||
|
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
|
||||||
|
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
|
||||||
|
//! local segment. The NIC's WoL logic matches the repeated MAC and
|
||||||
|
//! powers the board on.
|
||||||
|
//!
|
||||||
|
//! ## Why this is trivial and safe in our container
|
||||||
|
//!
|
||||||
|
//! - It's a single UDP datagram to a broadcast address. No privileged
|
||||||
|
//! *local* port is needed (we bind an ephemeral source port); the
|
||||||
|
//! destination port is conventionally 9 (discard) or 7 (echo), and
|
||||||
|
//! nothing actually listens there — the magic is in the payload, not
|
||||||
|
//! the port. So WoL works without any extra capability.
|
||||||
|
//! - We send to the limited broadcast `255.255.255.255` (stays on the
|
||||||
|
//! local link) and, when the caller knows the server's own subnet
|
||||||
|
//! broadcast, to that too — directed broadcast reaches the right VLAN
|
||||||
|
//! even when the host bridges multiple segments.
|
||||||
|
//!
|
||||||
|
//! ## Limits
|
||||||
|
//!
|
||||||
|
//! WoL only crosses L2. If the target is on a different subnet than the
|
||||||
|
//! OpenPXE host, the intervening router must be configured to forward
|
||||||
|
//! directed broadcasts (most aren't, by design). For the common case —
|
||||||
|
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
|
||||||
|
//! is enough.
|
||||||
|
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
|
||||||
|
|
||||||
|
/// Conventional WoL destination port. 9 (discard) is the de-facto
|
||||||
|
/// default; the port is immaterial since the match is on the payload.
|
||||||
|
const WOL_PORT: u16 = 9;
|
||||||
|
|
||||||
|
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
|
||||||
|
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
|
||||||
|
///
|
||||||
|
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
|
||||||
|
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
|
||||||
|
// Strip every non-hex-digit, then expect exactly 12 hex chars.
|
||||||
|
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
|
||||||
|
if hex.len() != 12 {
|
||||||
|
return Err(Error::Invalid(format!(
|
||||||
|
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
|
||||||
|
hex.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 6];
|
||||||
|
for (i, byte) in out.iter_mut().enumerate() {
|
||||||
|
// Each octet is two hex chars; unwrap is safe — we validated
|
||||||
|
// the length and that every char is a hex digit above.
|
||||||
|
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
||||||
|
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the 102-byte magic packet for `mac`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
|
||||||
|
let mut pkt = [0u8; 102];
|
||||||
|
// 6 bytes of 0xFF.
|
||||||
|
for b in &mut pkt[..6] {
|
||||||
|
*b = 0xFF;
|
||||||
|
}
|
||||||
|
// MAC repeated 16 times.
|
||||||
|
for rep in 0..16 {
|
||||||
|
let start = 6 + rep * 6;
|
||||||
|
pkt[start..start + 6].copy_from_slice(&mac);
|
||||||
|
}
|
||||||
|
pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN magic packet for `mac` to every address in
|
||||||
|
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
|
||||||
|
/// broadcast). Returns the number of broadcast addresses the packet was
|
||||||
|
/// successfully sent to; errors only if the MAC is malformed or the
|
||||||
|
/// socket can't be opened at all.
|
||||||
|
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
|
||||||
|
let parsed = parse_mac(mac)?;
|
||||||
|
let packet = magic_packet(parsed);
|
||||||
|
|
||||||
|
// Always include the limited broadcast even if the caller didn't —
|
||||||
|
// it's the one that works with zero network configuration.
|
||||||
|
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||||
|
for b in broadcasts {
|
||||||
|
if !targets.contains(b) {
|
||||||
|
targets.push(*b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sent = send_magic(&packet, &targets, WOL_PORT)?;
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::wol",
|
||||||
|
mac = %mac, broadcasts = sent,
|
||||||
|
"Wake-on-LAN magic packet sent"
|
||||||
|
);
|
||||||
|
Ok(sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a broadcast-enabled UDP socket and send `packet` to every
|
||||||
|
/// `target:port`. Returns how many sends succeeded. Errors if the
|
||||||
|
/// socket can't be opened or if *no* target accepted the packet.
|
||||||
|
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
|
||||||
|
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
|
||||||
|
// must be enabled to send to a broadcast address.
|
||||||
|
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
|
||||||
|
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
|
||||||
|
sock.set_broadcast(true)
|
||||||
|
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
|
||||||
|
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for &addr in targets {
|
||||||
|
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
|
||||||
|
Ok(_) => sent += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::wol",
|
||||||
|
broadcast = %addr,
|
||||||
|
"WoL send failed: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sent == 0 {
|
||||||
|
return Err(Error::Invalid(
|
||||||
|
"Wake-on-LAN: no broadcast address accepted the packet".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
|
||||||
|
/// Used so the caller can include the server's own subnet broadcast
|
||||||
|
/// alongside the limited broadcast.
|
||||||
|
#[must_use]
|
||||||
|
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
|
||||||
|
let ip = u32::from(ip);
|
||||||
|
let mask = u32::from(mask);
|
||||||
|
Ipv4Addr::from(ip | !mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mac_accepts_common_forms() {
|
||||||
|
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
||||||
|
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
|
||||||
|
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
|
||||||
|
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
|
||||||
|
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mac_rejects_bad_length() {
|
||||||
|
assert!(parse_mac("aa:bb:cc").is_err());
|
||||||
|
assert!(parse_mac("").is_err());
|
||||||
|
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_packet_shape() {
|
||||||
|
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||||
|
assert_eq!(&pkt[..6], &[0xFF; 6]);
|
||||||
|
// First MAC repetition.
|
||||||
|
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||||
|
// Last (16th) repetition ends the packet.
|
||||||
|
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subnet_broadcast_computes() {
|
||||||
|
assert_eq!(
|
||||||
|
subnet_broadcast(
|
||||||
|
Ipv4Addr::new(192, 168, 1, 49),
|
||||||
|
Ipv4Addr::new(255, 255, 255, 0)
|
||||||
|
),
|
||||||
|
Ipv4Addr::new(192, 168, 1, 255)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
subnet_broadcast(
|
||||||
|
Ipv4Addr::new(10, 5, 3, 7),
|
||||||
|
Ipv4Addr::new(255, 255, 0, 0)
|
||||||
|
),
|
||||||
|
Ipv4Addr::new(10, 5, 255, 255)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_magic_delivers_intact_packet_over_loopback() {
|
||||||
|
// Deterministic round-trip that doesn't depend on the sandbox
|
||||||
|
// permitting a real L2 broadcast: bind a receiver on loopback
|
||||||
|
// and confirm send_magic transmits the exact 102-byte packet.
|
||||||
|
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let port = rx.local_addr().unwrap().port();
|
||||||
|
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||||
|
|
||||||
|
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
||||||
|
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
||||||
|
assert_eq!(sent, 1);
|
||||||
|
|
||||||
|
let mut buf = [0u8; 128];
|
||||||
|
let n = rx.recv(&mut buf).unwrap();
|
||||||
|
assert_eq!(n, 102, "magic packet should be 102 bytes");
|
||||||
|
assert_eq!(&buf[..102], &packet[..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ mime_guess.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
|
||||||
|
# About-tab "check for updates" call to the Gitea releases API; SMTP for
|
||||||
|
# email notifications. Both use rustls so the static musl binary stays
|
||||||
|
# OpenSSL-free.
|
||||||
|
reqwest.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||||
|
|||||||
+275
-3
@@ -31,8 +31,8 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
|
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
|
||||||
MAX_LOGO_BYTES,
|
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||||
};
|
};
|
||||||
use openpxe_ipxe_assets::asset_bytes;
|
use openpxe_ipxe_assets::asset_bytes;
|
||||||
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
|
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
|
||||||
@@ -159,9 +159,19 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
// 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", 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))
|
||||||
// Rolling "host log" of boot events: what image actually
|
// Rolling "host log" of boot events: what image actually
|
||||||
// started installing on what MAC/IP, and when. Persisted to disk.
|
// started installing on what MAC/IP, and when. Persisted to disk.
|
||||||
.route("/api/boot-log", get(api_boot_log))
|
.route("/api/boot-log", get(api_boot_log))
|
||||||
|
// v0.5.0: notification config (Advanced tab) + a "send test"
|
||||||
|
// probe. GET redacts the SMTP password.
|
||||||
|
.route("/api/notify", get(api_notify_get).put(api_notify_put))
|
||||||
|
.route("/api/notify/test", post(api_notify_test))
|
||||||
|
// v0.5.0: About-tab update check — queries the Gitea releases
|
||||||
|
// API and compares against the running version.
|
||||||
|
.route("/api/updates/check", get(api_updates_check))
|
||||||
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
||||||
// format. No auth — the metrics surface is intentionally
|
// format. No auth — the metrics surface is intentionally
|
||||||
// boring (counts, no payloads).
|
// boring (counts, no payloads).
|
||||||
@@ -613,11 +623,22 @@ async fn boot_sub(
|
|||||||
.filter(|m| !m.is_empty());
|
.filter(|m| !m.is_empty());
|
||||||
state.boot_log.record(&BootEvent {
|
state.boot_log.record(&BootEvent {
|
||||||
timestamp: time::OffsetDateTime::now_utc(),
|
timestamp: time::OffsetDateTime::now_utc(),
|
||||||
mac: mac_normalized,
|
mac: mac_normalized.clone(),
|
||||||
ip: peer_ip,
|
ip: peer_ip,
|
||||||
target_id: entry.id.clone(),
|
target_id: entry.id.clone(),
|
||||||
target_title: format!("{} — {}", iso.filename, entry.title),
|
target_title: format!("{} — {}", iso.filename, entry.title),
|
||||||
});
|
});
|
||||||
|
// v0.5.0: fire-and-forget notification on the
|
||||||
|
// canonical "a machine is imaging" moment.
|
||||||
|
let who = mac_normalized
|
||||||
|
.clone()
|
||||||
|
.or_else(|| peer_ip.map(|ip| ip.to_string()))
|
||||||
|
.unwrap_or_else(|| "an unknown client".into());
|
||||||
|
spawn_notify(
|
||||||
|
&state,
|
||||||
|
"PXE boot started",
|
||||||
|
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
|
||||||
|
);
|
||||||
return text_plain(render_entry(entry, &settings, base));
|
return text_plain(render_entry(entry, &settings, base));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1247,10 +1268,25 @@ async fn api_docs() -> Json<serde_json::Value> {
|
|||||||
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
|
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
|
||||||
{"method": "DELETE", "path": "/api/hosts/:mac",
|
{"method": "DELETE", "path": "/api/hosts/:mac",
|
||||||
"summary": "Remove a binding."},
|
"summary": "Remove a binding."},
|
||||||
|
{"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",
|
{"method": "GET", "path": "/api/boot-log",
|
||||||
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Notifications & updates",
|
||||||
|
"endpoints": [
|
||||||
|
{"method": "GET", "path": "/api/notify",
|
||||||
|
"summary": "Current notification config (SMTP password redacted)."},
|
||||||
|
{"method": "PUT", "path": "/api/notify",
|
||||||
|
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
|
||||||
|
{"method": "POST", "path": "/api/notify/test",
|
||||||
|
"summary": "Send a test notification using the saved config."},
|
||||||
|
{"method": "GET", "path": "/api/updates/check",
|
||||||
|
"summary": "Compare the running version against the latest Gitea release."},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Operator console",
|
"name": "Operator console",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
@@ -2022,6 +2058,220 @@ async fn api_hosts_remove(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
|
||||||
|
/// broadcast (255.255.255.255) and the server's own subnet broadcast
|
||||||
|
/// (computed from the advertised IP + detected mask), which covers the
|
||||||
|
/// common "same VLAN as OpenPXE" case with zero network config. We only
|
||||||
|
/// wake MACs that are actually bound — keeps this from being an open
|
||||||
|
/// "spray packets at any MAC" endpoint.
|
||||||
|
async fn api_hosts_wol(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(mac): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
if state.hosts.lookup(&mac).is_none() {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"no host binding for that MAC — bind it first",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the server's subnet broadcast from the advertised IP +
|
||||||
|
// detected mask so the packet reaches the right VLAN even if the
|
||||||
|
// limited broadcast is filtered. Best-effort: skip if either won't
|
||||||
|
// parse.
|
||||||
|
let server_ip = state
|
||||||
|
.public_base_url
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.unwrap_or(&state.public_base_url)
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.parse::<std::net::Ipv4Addr>();
|
||||||
|
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
|
||||||
|
let mut broadcasts = Vec::new();
|
||||||
|
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
|
||||||
|
broadcasts.push(wol::subnet_broadcast(ip, m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The send is a blocking std UDP call; push it off the async
|
||||||
|
// executor.
|
||||||
|
let mac_owned = mac.clone();
|
||||||
|
let result =
|
||||||
|
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(n)) => {
|
||||||
|
// Fire-and-forget notification — nice "someone woke a box"
|
||||||
|
// signal, never blocks the response.
|
||||||
|
spawn_notify(
|
||||||
|
&state,
|
||||||
|
"Wake-on-LAN sent",
|
||||||
|
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
|
||||||
|
);
|
||||||
|
Json(json!({ "ok": true, "broadcasts": n })).into_response()
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
|
||||||
|
// Redact the SMTP password before it leaves the process.
|
||||||
|
Json(state.notify.snapshot().redacted())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_notify_put(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(cfg): Json<NotifyConfig>,
|
||||||
|
) -> Response {
|
||||||
|
match state.notify.replace(cfg) {
|
||||||
|
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
|
||||||
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a test notification using the *currently saved* config (not the
|
||||||
|
/// request body) so the operator validates exactly what's persisted.
|
||||||
|
async fn api_notify_test(State(state): State<AppState>) -> Response {
|
||||||
|
let cfg = state.notify.snapshot();
|
||||||
|
match crate::notify::send(
|
||||||
|
&cfg,
|
||||||
|
"OpenPXE test notification",
|
||||||
|
"If you're reading this, OpenPXE notifications are wired up correctly. \
|
||||||
|
This is a test from the Advanced settings tab.",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Json(json!({ "ok": true })).into_response(),
|
||||||
|
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Query the project's Gitea releases API for the latest published tag
|
||||||
|
/// and compare it to the running version. This is the only outbound
|
||||||
|
/// call OpenPXE makes that isn't operator-initiated data movement, and
|
||||||
|
/// it's strictly on-demand (the About tab's "Check for updates" button)
|
||||||
|
/// — never a background poll, keeping the air-gapped promise intact.
|
||||||
|
async fn api_updates_check() -> Response {
|
||||||
|
let current = env!("CARGO_PKG_VERSION");
|
||||||
|
let Some(api) = gitea_releases_api_url() else {
|
||||||
|
return Json(json!({
|
||||||
|
"current": current,
|
||||||
|
"error": "repository URL not configured at build time",
|
||||||
|
}))
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(8))
|
||||||
|
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return Json(json!({ "current": current, "error": format!("client: {e}") }))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.get(&api).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
|
||||||
|
let latest_tag = body
|
||||||
|
.get("tag_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let html_url = body
|
||||||
|
.get("html_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let update_available = version_is_newer(
|
||||||
|
latest_tag.trim_start_matches('v'),
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
Json(json!({
|
||||||
|
"current": current,
|
||||||
|
"latest": latest_tag,
|
||||||
|
"update_available": update_available,
|
||||||
|
"html_url": html_url,
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Ok(resp) => Json(json!({
|
||||||
|
"current": current,
|
||||||
|
"error": format!("releases API returned HTTP {}", resp.status()),
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => Json(json!({
|
||||||
|
"current": current,
|
||||||
|
"error": format!("could not reach the releases API: {e}"),
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the Gitea `releases/latest` API URL from the compile-time
|
||||||
|
/// repository URL (`https://host/owner/repo`).
|
||||||
|
fn gitea_releases_api_url() -> Option<String> {
|
||||||
|
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
|
||||||
|
let rest = repo
|
||||||
|
.strip_prefix("https://")
|
||||||
|
.or_else(|| repo.strip_prefix("http://"))?;
|
||||||
|
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
|
||||||
|
let host = parts.next()?;
|
||||||
|
let owner = parts.next()?;
|
||||||
|
let name = parts.next()?;
|
||||||
|
if host.is_empty() || owner.is_empty() || name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two dotted numeric versions; `true` when `latest` is strictly
|
||||||
|
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
|
||||||
|
/// a garbage tag never falsely reports an update.
|
||||||
|
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||||
|
fn parts(v: &str) -> Vec<u64> {
|
||||||
|
v.split('.')
|
||||||
|
.map(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>())
|
||||||
|
.map(|s| s.parse::<u64>().unwrap_or(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
let (l, c) = (parts(latest), parts(current));
|
||||||
|
for i in 0..l.len().max(c.len()) {
|
||||||
|
let lv = l.get(i).copied().unwrap_or(0);
|
||||||
|
let cv = c.get(i).copied().unwrap_or(0);
|
||||||
|
if lv != cv {
|
||||||
|
return lv > cv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire a notification on a detached task. Never blocks the caller and
|
||||||
|
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
|
||||||
|
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
|
||||||
|
let cfg = state.notify.snapshot();
|
||||||
|
if !cfg.is_usable() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let subject = subject.to_string();
|
||||||
|
let body = body.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
|
||||||
|
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Boot event log ───────────────────────────────────────────────────────
|
// ─── Boot event log ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||||
@@ -2081,6 +2331,28 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_newer_detects_updates() {
|
||||||
|
assert!(version_is_newer("0.5.1", "0.5.0"));
|
||||||
|
assert!(version_is_newer("0.6.0", "0.5.9"));
|
||||||
|
assert!(version_is_newer("1.0.0", "0.9.9"));
|
||||||
|
assert!(!version_is_newer("0.5.0", "0.5.0"));
|
||||||
|
assert!(!version_is_newer("0.4.69", "0.5.0"));
|
||||||
|
// A garbage / empty tag must never falsely report an update.
|
||||||
|
assert!(!version_is_newer("", "0.5.0"));
|
||||||
|
assert!(!version_is_newer("not-a-version", "0.5.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_api_url_derives_from_repo() {
|
||||||
|
// CARGO_PKG_REPOSITORY is set from the workspace manifest.
|
||||||
|
let url = gitea_releases_api_url();
|
||||||
|
if let Some(u) = url {
|
||||||
|
assert!(u.contains("/api/v1/repos/"), "got: {u}");
|
||||||
|
assert!(u.ends_with("/releases/latest"), "got: {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn range_full() {
|
fn range_full() {
|
||||||
let (s, e, p) = parse_range(None, 1000).unwrap();
|
let (s, e, p) = parse_range(None, 1000).unwrap();
|
||||||
|
|||||||
@@ -303,12 +303,20 @@ pub async fn api_logout(
|
|||||||
/// * `authenticated: false` — admin exists, no session; show login.
|
/// * `authenticated: false` — admin exists, no session; show login.
|
||||||
/// * `authenticated: true` + `user` — let the dashboard load.
|
/// * `authenticated: true` + `user` — let the dashboard load.
|
||||||
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||||
|
// v0.5.0: include branding bootstrap so the pre-auth login/setup
|
||||||
|
// screens can render the FleetDM-style full-width custom logo (and
|
||||||
|
// cache-bust it) without an extra round trip. `/api/me` is public,
|
||||||
|
// and the logo asset is public, so this leaks nothing sensitive.
|
||||||
|
let has_custom_logo = state.branding.has_logo();
|
||||||
|
let logo_rev = state.branding.logo_rev();
|
||||||
if !state.admin.is_configured() {
|
if !state.admin.is_configured() {
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"setup_required": true,
|
"setup_required": true,
|
||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
|
"has_custom_logo": has_custom_logo,
|
||||||
|
"logo_rev": logo_rev,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -323,6 +331,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"user": state.admin.snapshot(),
|
"user": state.admin.snapshot(),
|
||||||
"session_user": u,
|
"session_user": u,
|
||||||
|
"has_custom_logo": has_custom_logo,
|
||||||
|
"logo_rev": logo_rev,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -331,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
Json(json!({
|
Json(json!({
|
||||||
"setup_required": false,
|
"setup_required": false,
|
||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
|
"has_custom_logo": has_custom_logo,
|
||||||
|
"logo_rev": logo_rev,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod auth;
|
|||||||
pub mod ipxe_script;
|
pub mod ipxe_script;
|
||||||
pub mod iso_fs;
|
pub mod iso_fs;
|
||||||
pub mod log_stream;
|
pub mod log_stream;
|
||||||
|
pub mod notify;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Notification *delivery* — the network half of the notify feature.
|
||||||
|
//!
|
||||||
|
//! `openpxe_core::notify` owns the config + persistence; this module
|
||||||
|
//! turns a `NotifyConfig` + a message into an actual delivery:
|
||||||
|
//!
|
||||||
|
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
|
||||||
|
//! body to the operator's incoming-webhook URL (via `reqwest`).
|
||||||
|
//! - SMTP → a TLS email via `lettre`.
|
||||||
|
//!
|
||||||
|
//! Every send is best-effort and time-bounded: a flaky webhook must
|
||||||
|
//! never wedge a PXE boot. Callers fire these from a detached task.
|
||||||
|
|
||||||
|
use openpxe_core::{NotifyConfig, NotifyKind};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
|
||||||
|
/// task forever.
|
||||||
|
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Deliver `body` (with an optional `subject`, used as the email
|
||||||
|
/// subject / chat bold-line) using the active provider in `cfg`.
|
||||||
|
/// Returns `Ok(())` on success, or a human-readable error suitable for
|
||||||
|
/// surfacing in the "Send test" response.
|
||||||
|
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||||
|
if !cfg.is_usable() {
|
||||||
|
return Err("notifications are not enabled / fully configured".into());
|
||||||
|
}
|
||||||
|
match cfg.kind {
|
||||||
|
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
|
||||||
|
send_webhook(cfg, subject, body).await
|
||||||
|
}
|
||||||
|
NotifyKind::Smtp => send_email(cfg, subject, body).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||||
|
// Each chat platform wants a different JSON shape for an incoming
|
||||||
|
// webhook. Keep the bodies minimal and plain-text-ish so they
|
||||||
|
// render cleanly everywhere.
|
||||||
|
let combined = if subject.is_empty() {
|
||||||
|
body.to_string()
|
||||||
|
} else {
|
||||||
|
format!("*{subject}*\n{body}")
|
||||||
|
};
|
||||||
|
let payload = match cfg.kind {
|
||||||
|
NotifyKind::Slack => serde_json::json!({ "text": combined }),
|
||||||
|
NotifyKind::Discord => serde_json::json!({ "content": combined }),
|
||||||
|
NotifyKind::Teams => serde_json::json!({
|
||||||
|
// Legacy MessageCard — the format every Teams "Incoming
|
||||||
|
// Webhook" connector still accepts.
|
||||||
|
"@type": "MessageCard",
|
||||||
|
"@context": "https://schema.org/extensions",
|
||||||
|
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
|
||||||
|
"title": subject,
|
||||||
|
"text": body,
|
||||||
|
}),
|
||||||
|
NotifyKind::Smtp => unreachable!("smtp handled separately"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(SEND_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("could not build HTTP client: {e}"))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&cfg.webhook_url)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("webhook POST failed: {e}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let snippet = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.chars()
|
||||||
|
.take(200)
|
||||||
|
.collect::<String>();
|
||||||
|
Err(format!("webhook returned HTTP {status}: {snippet}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
|
|
||||||
|
let from = if cfg.smtp_from.trim().is_empty() {
|
||||||
|
cfg.smtp_username.trim()
|
||||||
|
} else {
|
||||||
|
cfg.smtp_from.trim()
|
||||||
|
};
|
||||||
|
if from.is_empty() {
|
||||||
|
return Err("SMTP requires a From address (or a username to fall back to)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = Message::builder()
|
||||||
|
.from(
|
||||||
|
from.parse()
|
||||||
|
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
|
||||||
|
)
|
||||||
|
.to(cfg
|
||||||
|
.smtp_to
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
|
||||||
|
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
|
||||||
|
.body(body.to_string())
|
||||||
|
.map_err(|e| format!("could not build email: {e}"))?;
|
||||||
|
|
||||||
|
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
|
||||||
|
let mut builder = if cfg.smtp_implicit_tls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
|
||||||
|
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
|
||||||
|
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
|
||||||
|
}
|
||||||
|
.port(cfg.smtp_port)
|
||||||
|
.timeout(Some(SEND_TIMEOUT));
|
||||||
|
|
||||||
|
// Auth is optional — some internal relays accept unauthenticated
|
||||||
|
// mail from trusted hosts. Only attach credentials when a username
|
||||||
|
// is set.
|
||||||
|
if !cfg.smtp_username.trim().is_empty() {
|
||||||
|
builder = builder.credentials(Credentials::new(
|
||||||
|
cfg.smtp_username.trim().to_string(),
|
||||||
|
cfg.smtp_password.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mailer = builder.build();
|
||||||
|
mailer
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("SMTP send failed: {e}"))
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use crate::uploads::UploadSessions;
|
|||||||
use crate::auth::SessionStore;
|
use crate::auth::SessionStore;
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||||
Metrics, SettingsStore, SsoStore,
|
Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||||
};
|
};
|
||||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -37,6 +37,10 @@ pub struct AppState {
|
|||||||
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
||||||
/// flow ships in a later release.
|
/// flow ships in a later release.
|
||||||
pub sso: SsoStore,
|
pub sso: SsoStore,
|
||||||
|
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
|
||||||
|
/// SMTP). Drives the fire-and-forget pings on boot events and powers
|
||||||
|
/// the Advanced tab's config + "Send test" button.
|
||||||
|
pub notify: NotifyStore,
|
||||||
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
||||||
/// text format. Cheap to clone (handles to atomics).
|
/// text format. Cheap to clone (handles to atomics).
|
||||||
pub metrics: Metrics,
|
pub metrics: Metrics,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
|
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
|
||||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||||
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
|
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
|
||||||
|
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
|
||||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -115,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
admin,
|
admin,
|
||||||
sessions,
|
sessions,
|
||||||
sso,
|
sso,
|
||||||
|
notify,
|
||||||
metrics,
|
metrics,
|
||||||
smb: None,
|
smb: None,
|
||||||
smb_shares,
|
smb_shares,
|
||||||
@@ -535,6 +537,54 @@ async fn smb_shares_list_starts_empty() {
|
|||||||
|
|
||||||
// v0.4.67: NFSv3 share manager (parallel to SMB).
|
// v0.4.67: NFSv3 share manager (parallel to SMB).
|
||||||
|
|
||||||
|
// ── v0.5.0: notifications + Wake-on-LAN ────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notify_config_round_trips_and_redacts_smtp_password() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
// Save an SMTP config with a password.
|
||||||
|
let (s, _b) = put_json(
|
||||||
|
&app,
|
||||||
|
"/api/notify",
|
||||||
|
r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"[email protected]","smtp_from":"[email protected]","smtp_password":"s3cret"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
// GET must redact the password (never echo the real secret).
|
||||||
|
let (s, b) = get(&app, "/api/notify").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["enabled"], true);
|
||||||
|
assert_eq!(v["kind"], "smtp");
|
||||||
|
let pw = v["smtp_password"].as_str().unwrap_or("");
|
||||||
|
assert_ne!(pw, "s3cret", "raw password must never be returned");
|
||||||
|
assert!(!pw.is_empty(), "a set password should surface as a sentinel");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notify_enable_webhook_without_url_is_rejected() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, _b) = put_json(
|
||||||
|
&app,
|
||||||
|
"/api/notify",
|
||||||
|
r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wol_on_unbound_mac_is_404() {
|
||||||
|
// WoL only fires for bound MACs — an arbitrary MAC must 404 so the
|
||||||
|
// endpoint isn't an open packet sprayer.
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nfs_shares_list_starts_empty() {
|
async fn nfs_shares_list_starts_empty() {
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
|
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
|
||||||
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
||||||
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
|
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
|
||||||
|
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
|
||||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
sessions: sessions.clone(),
|
sessions: sessions.clone(),
|
||||||
sso: sso.clone(),
|
sso: sso.clone(),
|
||||||
|
notify: notify.clone(),
|
||||||
metrics: metrics.clone(),
|
metrics: metrics.clone(),
|
||||||
smb: Some(smb.clone()),
|
smb: Some(smb.clone()),
|
||||||
smb_shares: smb_shares.clone(),
|
smb_shares: smb_shares.clone(),
|
||||||
|
|||||||
@@ -627,6 +627,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
}
|
}
|
||||||
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
|
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
|
||||||
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
|
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
|
||||||
|
/* v0.5.0: FleetDM-style custom logo on the login/setup card — the
|
||||||
|
uploaded logo spans the card header and the "OpenPXE" wordmark is
|
||||||
|
dropped (the logo is the brand). Matches the sidebar treatment. */
|
||||||
|
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
|
||||||
|
.auth-card .brand-row.has-custom-logo img {
|
||||||
|
width: auto; height: 52px; max-width: 240px;
|
||||||
|
object-fit: contain; object-position: center;
|
||||||
|
}
|
||||||
.auth-card h2 {
|
.auth-card h2 {
|
||||||
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
|
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-32
@@ -973,18 +973,37 @@
|
|||||||
}
|
}
|
||||||
}}, 'Bind MAC to target');
|
}}, 'Bind MAC to target');
|
||||||
|
|
||||||
const rows = hosts.map(h => el('tr', {}, [
|
const rows = hosts.map(h => {
|
||||||
el('td', {class:'mono'}, h.mac),
|
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
// table is bound hosts). The button reports its own state inline
|
||||||
el('td', {class:'mono'}, h.target),
|
// so there's no shared toast to thread through.
|
||||||
el('td', {}, fmtAgo(h.updated_at)),
|
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
|
||||||
el('td', {style:'text-align:right'},
|
wakeBtn.disabled = true;
|
||||||
el('button', {class:'danger', onclick: async () => {
|
const original = wakeBtn.textContent;
|
||||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
wakeBtn.textContent = 'Waking…';
|
||||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
try {
|
||||||
render('hosts');
|
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
|
||||||
}}, 'Remove')),
|
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
|
||||||
]));
|
} catch (e) {
|
||||||
|
wakeBtn.textContent = 'Failed';
|
||||||
|
}
|
||||||
|
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
|
||||||
|
}}, 'Wake');
|
||||||
|
return el('tr', {}, [
|
||||||
|
el('td', {class:'mono'}, h.mac),
|
||||||
|
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||||||
|
el('td', {class:'mono'}, h.target),
|
||||||
|
el('td', {}, fmtAgo(h.updated_at)),
|
||||||
|
el('td', {style:'text-align:right;white-space:nowrap'}, [
|
||||||
|
wakeBtn,
|
||||||
|
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
|
||||||
|
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||||||
|
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||||||
|
render('hosts');
|
||||||
|
}}, 'Remove'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
const table = hosts.length
|
const table = hosts.length
|
||||||
? el('table', {}, [
|
? el('table', {}, [
|
||||||
@@ -1185,9 +1204,8 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
settings: async () => {
|
settings: async () => {
|
||||||
const [status, docs, me, sso] = await Promise.all([
|
const [status, me, sso] = await Promise.all([
|
||||||
getJSON('/api/status'),
|
getJSON('/api/status'),
|
||||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
|
||||||
getJSON('/api/me').catch(() => ({})),
|
getJSON('/api/me').catch(() => ({})),
|
||||||
getJSON('/api/sso').catch(() => ({
|
getJSON('/api/sso').catch(() => ({
|
||||||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||||||
@@ -1489,10 +1507,133 @@
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── API reference (always at the bottom of Settings).
|
// v0.5.0: the API reference moved to the Advanced tab; Settings
|
||||||
// Sourced from /api/docs so the hand-curated list stays the
|
// now holds just account / SSO / branding.
|
||||||
// single source of truth and the UI doesn't need its own copy
|
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
|
||||||
// baked into the JS bundle.
|
},
|
||||||
|
|
||||||
|
// v0.5.0: Advanced settings — webhook/email notifications, and the
|
||||||
|
// API reference (relocated from the bottom of Settings).
|
||||||
|
advanced: async () => {
|
||||||
|
const [notify, docs] = await Promise.all([
|
||||||
|
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||||
|
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Notification config ──
|
||||||
|
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||||
|
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
|
||||||
|
const nKind = el('select', {}, [
|
||||||
|
el('option', {value:'slack'}, 'Slack'),
|
||||||
|
el('option', {value:'discord'}, 'Discord'),
|
||||||
|
el('option', {value:'teams'}, 'Microsoft Teams'),
|
||||||
|
el('option', {value:'smtp'}, 'Email (SMTP)'),
|
||||||
|
]);
|
||||||
|
nKind.value = notify.kind || 'slack';
|
||||||
|
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
|
||||||
|
value: notify.webhook_url || ''});
|
||||||
|
// SMTP fields.
|
||||||
|
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
|
||||||
|
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
|
||||||
|
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
|
||||||
|
// GET returns the password as a redaction sentinel when one is
|
||||||
|
// set; show an empty field with an "(unchanged)" placeholder and
|
||||||
|
// let collectNotify() re-send the sentinel so the stored secret
|
||||||
|
// is preserved unless the operator types a new one.
|
||||||
|
const hasStoredPass = !!notify.smtp_password;
|
||||||
|
const sPass = el('input', {type:'password',
|
||||||
|
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
|
||||||
|
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
|
||||||
|
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
|
||||||
|
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
|
||||||
|
|
||||||
|
const webhookBlock = el('div', {class:'form-row'}, [
|
||||||
|
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||||
|
el('span', {class:'name'}, 'Incoming webhook URL'),
|
||||||
|
nWebhook,
|
||||||
|
el('span', {class:'hint'},
|
||||||
|
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const smtpBlock = el('div', {}, [
|
||||||
|
el('div', {class:'form-row cols-2'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
|
||||||
|
]),
|
||||||
|
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
|
||||||
|
]),
|
||||||
|
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
|
||||||
|
]),
|
||||||
|
el('label', {class:'check', style:'margin-top:12px'}, [
|
||||||
|
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
// Toggle which provider block shows.
|
||||||
|
const syncKind = () => {
|
||||||
|
const smtp = nKind.value === 'smtp';
|
||||||
|
webhookBlock.style.display = smtp ? 'none' : '';
|
||||||
|
smtpBlock.style.display = smtp ? '' : 'none';
|
||||||
|
};
|
||||||
|
nKind.addEventListener('change', syncKind);
|
||||||
|
syncKind();
|
||||||
|
|
||||||
|
const collectNotify = () => ({
|
||||||
|
enabled: nEnabled.checked,
|
||||||
|
kind: nKind.value,
|
||||||
|
webhook_url: nWebhook.value,
|
||||||
|
smtp_host: sHost.value,
|
||||||
|
smtp_port: Number(sPort.value) || 587,
|
||||||
|
smtp_username: sUser.value,
|
||||||
|
// Empty field + a stored password → send the sentinel so the
|
||||||
|
// server keeps it. Otherwise send whatever was typed (a new
|
||||||
|
// password, or empty to clear when none was stored).
|
||||||
|
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
|
||||||
|
smtp_from: sFrom.value,
|
||||||
|
smtp_to: sTo.value,
|
||||||
|
smtp_implicit_tls: sTls.checked,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||||
|
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||||
|
const r = await putJSON('/api/notify', collectNotify());
|
||||||
|
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
|
||||||
|
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||||
|
}}, 'Save notification settings');
|
||||||
|
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
||||||
|
onclick: async () => {
|
||||||
|
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
||||||
|
// Save first so the test uses exactly what's on screen.
|
||||||
|
const rs = await putJSON('/api/notify', collectNotify());
|
||||||
|
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
|
||||||
|
const r = await postJSON('/api/notify/test', {});
|
||||||
|
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
|
||||||
|
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||||
|
}}, 'Send test');
|
||||||
|
|
||||||
|
const notifyCard = el('div', {class:'card'}, [
|
||||||
|
el('header', {}, [
|
||||||
|
el('h2', {}, 'Webhook notifications'),
|
||||||
|
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
|
||||||
|
]),
|
||||||
|
el('div', {class:'body'}, [
|
||||||
|
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||||
|
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
|
||||||
|
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
|
||||||
|
'(or SMTP details), and Send test.'),
|
||||||
|
el('div', {class:'form-row cols-2'}, [
|
||||||
|
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
|
||||||
|
]),
|
||||||
|
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
|
||||||
|
saveBtn, testBtn, nMsg,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── API reference (relocated from Settings) ──
|
||||||
const groups = docs.groups || [];
|
const groups = docs.groups || [];
|
||||||
const apiCard = el('div', {class:'card'}, [
|
const apiCard = el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
@@ -1515,12 +1656,41 @@
|
|||||||
'No API documentation returned by /api/docs.')),
|
'No API documentation returned by /api/docs.')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
|
return el('div', {class:'grid'}, [notifyCard, apiCard]);
|
||||||
},
|
},
|
||||||
|
|
||||||
about: async () => {
|
about: async () => {
|
||||||
const status = await getJSON('/api/status');
|
const status = await getJSON('/api/status');
|
||||||
return el('div', {class:'card'}, [
|
|
||||||
|
// ── Check for updates ──
|
||||||
|
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||||
|
const updBtn = el('button', {onclick: async () => {
|
||||||
|
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
|
||||||
|
try {
|
||||||
|
const r = await getJSON('/api/updates/check');
|
||||||
|
if (r.error) {
|
||||||
|
updMsg.replaceChildren(document.createTextNode('Couldn’t check: ' + r.error));
|
||||||
|
updMsg.className = 'msg err';
|
||||||
|
} else if (r.update_available) {
|
||||||
|
const parts = [document.createTextNode('Update available: '),
|
||||||
|
el('strong', {}, r.latest), document.createTextNode(' (you’re on ' + r.current + '). ')];
|
||||||
|
if (r.html_url) {
|
||||||
|
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
|
||||||
|
'View release →'));
|
||||||
|
}
|
||||||
|
updMsg.replaceChildren(...parts);
|
||||||
|
updMsg.className = 'msg ok';
|
||||||
|
} else {
|
||||||
|
updMsg.replaceChildren(document.createTextNode(
|
||||||
|
'You’re up to date — running the latest release (' + r.current + ').'));
|
||||||
|
updMsg.className = 'msg ok';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
updMsg.textContent = 'Couldn’t reach the release feed (offline?).'; updMsg.className = 'msg err';
|
||||||
|
}
|
||||||
|
}}, 'Check for updates');
|
||||||
|
|
||||||
|
const heroCard = el('div', {class:'card'}, [
|
||||||
el('div', {class:'about-hero'}, [
|
el('div', {class:'about-hero'}, [
|
||||||
el('h2', {}, 'OpenPXE'),
|
el('h2', {}, 'OpenPXE'),
|
||||||
el('p', {class:'lead'},
|
el('p', {class:'lead'},
|
||||||
@@ -1535,6 +1705,7 @@
|
|||||||
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
||||||
'https://openpxe.com/'),
|
'https://openpxe.com/'),
|
||||||
]),
|
]),
|
||||||
|
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||||
el('p', {class:'msg', style:'margin-top:18px'},
|
el('p', {class:'msg', style:'margin-top:18px'},
|
||||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||||
'executes is generated from the settings on these tabs — there is no ' +
|
'executes is generated from the settings on these tabs — there is no ' +
|
||||||
@@ -1547,6 +1718,46 @@
|
|||||||
'flip "testsigning" on a target machine.'),
|
'flip "testsigning" on a target machine.'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ── Licenses ──
|
||||||
|
const licenseCard = el('div', {class:'card'}, [
|
||||||
|
el('header', {}, el('h2', {}, 'License')),
|
||||||
|
el('div', {class:'body'}, [
|
||||||
|
el('p', {class:'msg', style:'margin-bottom:12px'}, [
|
||||||
|
'OpenPXE is dual-licensed under ',
|
||||||
|
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
|
||||||
|
el('strong', {}, 'Apache License 2.0'),
|
||||||
|
document.createTextNode(
|
||||||
|
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
|
||||||
|
]),
|
||||||
|
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
|
||||||
|
el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px'},
|
||||||
|
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
|
||||||
|
'of this software and associated documentation files, to deal in the Software ' +
|
||||||
|
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
|
||||||
|
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
|
||||||
|
'Full MIT text →'),
|
||||||
|
]),
|
||||||
|
el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px'},
|
||||||
|
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
|
||||||
|
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
|
||||||
|
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
|
||||||
|
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
|
||||||
|
'Full Apache 2.0 text →'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
|
||||||
|
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
|
||||||
|
'wimtools, and the Rust crates in this build. See the source repository for the ' +
|
||||||
|
'complete NOTICE.'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return el('div', {class:'grid'}, [heroCard, licenseCard]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1559,6 +1770,7 @@
|
|||||||
hosts: 'Hosts',
|
hosts: 'Hosts',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
advanced: 'Advanced',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1663,6 +1875,26 @@
|
|||||||
let chipsInterval = null;
|
let chipsInterval = null;
|
||||||
let authScreenEl = null;
|
let authScreenEl = null;
|
||||||
let ssoConfig = null;
|
let ssoConfig = null;
|
||||||
|
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
|
||||||
|
// /api/me (public). Lets the login/setup cards render the same
|
||||||
|
// FleetDM-style full-width custom logo the dashboard sidebar uses.
|
||||||
|
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
|
||||||
|
|
||||||
|
// Brand row for the auth screens. With a custom logo uploaded: the
|
||||||
|
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
|
||||||
|
// Default: bundled mark + "OpenPXE".
|
||||||
|
function authBrandRow() {
|
||||||
|
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
|
||||||
|
if (brandInfo.has_custom_logo) {
|
||||||
|
return el('div', {class:'brand-row has-custom-logo'}, [
|
||||||
|
el('img', {src, alt:'logo'}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return el('div', {class:'brand-row'}, [
|
||||||
|
el('img', {src, alt:''}),
|
||||||
|
el('div', {class:'name'}, 'OpenPXE'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function teardownAuthScreen() {
|
function teardownAuthScreen() {
|
||||||
if (authScreenEl && authScreenEl.parentNode) {
|
if (authScreenEl && authScreenEl.parentNode) {
|
||||||
@@ -1719,10 +1951,7 @@
|
|||||||
submit.textContent = 'Sign in';
|
submit.textContent = 'Sign in';
|
||||||
}
|
}
|
||||||
}}, [
|
}}, [
|
||||||
el('div', {class:'brand-row'}, [
|
authBrandRow(),
|
||||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
|
||||||
el('div', {class:'name'}, 'OpenPXE'),
|
|
||||||
]),
|
|
||||||
el('h2', {}, 'Sign in'),
|
el('h2', {}, 'Sign in'),
|
||||||
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
|
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
@@ -1787,10 +2016,7 @@
|
|||||||
submit.textContent = 'Create administrator';
|
submit.textContent = 'Create administrator';
|
||||||
}
|
}
|
||||||
}}, [
|
}}, [
|
||||||
el('div', {class:'brand-row'}, [
|
authBrandRow(),
|
||||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
|
||||||
el('div', {class:'name'}, 'OpenPXE'),
|
|
||||||
]),
|
|
||||||
el('h2', {}, 'First-run setup'),
|
el('h2', {}, 'First-run setup'),
|
||||||
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
|
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
@@ -1906,15 +2132,17 @@
|
|||||||
let me;
|
let me;
|
||||||
try {
|
try {
|
||||||
me = await fetch('/api/me').then(r => r.json());
|
me = await fetch('/api/me').then(r => r.json());
|
||||||
|
// Capture branding so the auth cards render the custom logo.
|
||||||
|
brandInfo = {
|
||||||
|
has_custom_logo: !!me.has_custom_logo,
|
||||||
|
logo_rev: me.logo_rev || 0,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// /api/me is unauthenticated in every state — if we can't reach
|
// /api/me is unauthenticated in every state — if we can't reach
|
||||||
// it the server is genuinely down, not an auth problem.
|
// it the server is genuinely down, not an auth problem.
|
||||||
document.body.appendChild(el('div', {class:'auth-screen'},
|
document.body.appendChild(el('div', {class:'auth-screen'},
|
||||||
el('div', {class:'auth-card'}, [
|
el('div', {class:'auth-card'}, [
|
||||||
el('div', {class:'brand-row'}, [
|
authBrandRow(),
|
||||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
|
||||||
el('div', {class:'name'}, 'OpenPXE'),
|
|
||||||
]),
|
|
||||||
el('h2', {}, 'Connection error'),
|
el('h2', {}, 'Connection error'),
|
||||||
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
|
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
|
||||||
])));
|
])));
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a data-view="terminal">Terminal</a>
|
<a data-view="terminal">Terminal</a>
|
||||||
<a data-view="settings">Settings</a>
|
<a data-view="settings">Settings</a>
|
||||||
|
<a data-view="advanced">Advanced</a>
|
||||||
<a data-view="about">About</a>
|
<a data-view="about">About</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user