From fc99973ac3f9ed72d90de8f6186282367182c863 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Fri, 29 May 2026 14:34:20 -0400 Subject: [PATCH] v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 500 +++++++++++++++++++++++++++-- Cargo.toml | 9 +- crates/core/src/lib.rs | 3 + crates/core/src/notify.rs | 362 +++++++++++++++++++++ crates/core/src/wol.rs | 210 ++++++++++++ crates/http-api/Cargo.toml | 6 + crates/http-api/src/app.rs | 278 +++++++++++++++- crates/http-api/src/auth.rs | 12 + crates/http-api/src/lib.rs | 1 + crates/http-api/src/notify.rs | 138 ++++++++ crates/http-api/src/state.rs | 6 +- crates/http-api/tests/full_flow.rs | 50 +++ crates/openpxe/src/main.rs | 2 + crates/webui/src/app.css | 8 + crates/webui/src/app.js | 292 +++++++++++++++-- crates/webui/src/index.html | 1 + 16 files changed, 1817 insertions(+), 61 deletions(-) create mode 100644 crates/core/src/notify.rs create mode 100644 crates/core/src/wol.rs create mode 100644 crates/http-api/src/notify.rs diff --git a/Cargo.lock b/Cargo.lock index f3de7df..5b94f83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,12 +248,28 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cipher" version = "0.4.4" @@ -369,8 +385,8 @@ dependencies = [ "dhcproto-macros", "hex", "ipnet", - "rand", - "thiserror", + "rand 0.8.6", + "thiserror 1.0.69", "trust-dns-proto", "url", ] @@ -402,6 +418,22 @@ dependencies = [ "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]] name = "encoding_rs" version = "0.8.35" @@ -454,6 +486,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -590,8 +628,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "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]] @@ -602,7 +656,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -682,6 +736,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "http" version = "1.4.0" @@ -752,6 +817,23 @@ dependencies = [ "pin-project-lite", "smallvec", "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]] @@ -760,13 +842,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.3", "tokio", "tower-service", + "tracing", ] [[package]] @@ -944,6 +1034,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "is_terminal_polyfill" version = "1.70.2" @@ -962,6 +1062,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -978,6 +1080,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "libc" version = "0.2.185" @@ -1011,6 +1141,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1133,6 +1269,15 @@ dependencies = [ "nfs3_macros", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1171,7 +1316,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -1193,7 +1338,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "bcrypt", @@ -1201,7 +1346,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "toml", @@ -1212,21 +1357,21 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "bytes", "dhcproto", "openpxe-core", "socket2 0.5.10", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] [[package]] name = "openpxe-http-api" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -1234,6 +1379,7 @@ dependencies = [ "futures", "hyper", "image", + "lettre", "mime", "mime_guess", "openpxe-core", @@ -1241,10 +1387,11 @@ dependencies = [ "openpxe-iso-store", "openpxe-webui", "parking_lot", + "reqwest", "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "tokio-stream", @@ -1257,17 +1404,17 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.69" +version = "0.5.0" dependencies = [ "openpxe-core", "rust-embed", - "thiserror", + "thiserror 1.0.69", "tracing", ] [[package]] name = "openpxe-iso-store" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "bcrypt", @@ -1284,7 +1431,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "tokio-util", @@ -1294,21 +1441,21 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.69" +version = "0.5.0" dependencies = [ "anyhow", "bytes", "openpxe-core", "openpxe-ipxe-assets", "socket2 0.5.10", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] [[package]] name = "openpxe-webui" -version = "0.4.69" +version = "0.5.0" [[package]] name = "parking_lot" @@ -1413,6 +1560,61 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "quote" version = "1.0.45" @@ -1422,6 +1624,18 @@ dependencies = [ "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]] name = "r-efi" version = "6.0.0" @@ -1435,8 +1649,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "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]] @@ -1446,7 +1670,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "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]] @@ -1458,6 +1692,15 @@ dependencies = [ "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]] name = "redox_syscall" version = "0.5.18" @@ -1484,6 +1727,61 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rust-embed" version = "8.11.0" @@ -1519,6 +1817,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -1532,6 +1836,42 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" @@ -1660,6 +2000,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1759,6 +2105,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1790,7 +2139,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 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]] @@ -1804,6 +2162,17 @@ dependencies = [ "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]] name = "thread_local" version = "1.1.9" @@ -1897,6 +2266,16 @@ dependencies = [ "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]] name = "tokio-stream" version = "0.1.18" @@ -1994,12 +2373,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -2108,14 +2489,20 @@ dependencies = [ "idna 0.2.3", "ipnet", "lazy_static", - "rand", + "rand 0.8.6", "smallvec", - "thiserror", + "thiserror 1.0.69", "tinyvec", "tracing", "url", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -2155,6 +2542,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2213,6 +2606,15 @@ dependencies = [ "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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2250,6 +2652,16 @@ dependencies = [ "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]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -2304,6 +2716,19 @@ dependencies = [ "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]] name = "wasmparser" version = "0.244.0" @@ -2316,6 +2741,35 @@ dependencies = [ "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]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index c8ff2ec..d226f2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.69" +version = "0.5.0" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] } tower = "0.5" tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } 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_guess = "2.0" @@ -66,6 +66,11 @@ rust-embed = { version = "8.5", features = ["include-exclude"] } nfs3_client = { version = "0.9", features = ["tokio"] } 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-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-tftp = { path = "crates/tftp" } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2efb705..a3813b7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -12,9 +12,11 @@ pub mod error; pub mod host_bindings; pub mod log_bus; pub mod metrics; +pub mod notify; pub mod queue; pub mod settings; pub mod sso; +pub mod wol; pub use arch::{ClientArch, FirmwareClass}; 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 log_bus::{LogBus, LogBusLayer, LogLine}; pub use metrics::{HttpRoute, Metrics}; +pub use notify::{NotifyConfig, NotifyKind, NotifyStore}; pub use queue::{DeploymentQueue, QueueEntry}; pub use settings::{Settings, SettingsStore, TimeoutAction}; diff --git a/crates/core/src/notify.rs b/crates/core/src/notify.rs new file mode 100644 index 0000000..6d7c2f0 --- /dev/null +++ b/crates/core/src/notify.rs @@ -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 `/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, + inner: Arc>, +} + +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::(&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 { + 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: "ops@example.com".into(), + smtp_from: "openpxe@example.com".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: "ops@example.com".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: "ops@example.com".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(_)))); + } +} diff --git a/crates/core/src/wol.rs b/crates/core/src/wol.rs new file mode 100644 index 0000000..d4b19dd --- /dev/null +++ b/crates/core/src/wol.rs @@ -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 { + 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 = 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 { + // 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[..]); + } +} diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index dc94340..79214ac 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -34,6 +34,12 @@ mime_guess.workspace = true uuid.workspace = true # v0.4.5 Forms auth: lock-free session store and cookie helpers. 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] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index d536227..6c07cbb 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -31,8 +31,8 @@ use axum::{ Json, Router, }; use openpxe_core::{ - ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES, - MAX_LOGO_BYTES, + ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig, + ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; 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. .route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert)) .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 // started installing on what MAC/IP, and when. Persisted to disk. .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 // format. No auth — the metrics surface is intentionally // boring (counts, no payloads). @@ -613,11 +623,22 @@ async fn boot_sub( .filter(|m| !m.is_empty()); state.boot_log.record(&BootEvent { timestamp: time::OffsetDateTime::now_utc(), - mac: mac_normalized, + mac: mac_normalized.clone(), ip: peer_ip, target_id: entry.id.clone(), 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)); } } @@ -1247,10 +1268,25 @@ async fn api_docs() -> Json { "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."}, {"method": "DELETE", "path": "/api/hosts/:mac", "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", "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", "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, + AxumPath(mac): AxumPath, +) -> 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::(); + let mask = state.subnet_mask.parse::(); + 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) -> Json { + // Redact the SMTP password before it leaves the process. + Json(state.notify.snapshot().redacted()) +} + +async fn api_notify_put( + State(state): State, + Json(cfg): Json, +) -> 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) -> 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 { + 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 { + v.split('.') + .map(|p| p.chars().take_while(char::is_ascii_digit).collect::()) + .map(|s| s.parse::().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 ─────────────────────────────────────────────────────── async fn api_boot_log(State(state): State) -> Json { @@ -2081,6 +2331,28 @@ async fn api_metrics(State(state): State) -> Response { mod tests { 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] fn range_full() { let (s, e, p) = parse_range(None, 1000).unwrap(); diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index 8d0ec93..5879b36 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -303,12 +303,20 @@ pub async fn api_logout( /// * `authenticated: false` — admin exists, no session; show login. /// * `authenticated: true` + `user` — let the dashboard load. pub async fn api_me(State(state): State, 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() { return ( StatusCode::OK, Json(json!({ "setup_required": true, "authenticated": false, + "has_custom_logo": has_custom_logo, + "logo_rev": logo_rev, })), ) .into_response(); @@ -323,6 +331,8 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa "authenticated": true, "user": state.admin.snapshot(), "session_user": u, + "has_custom_logo": has_custom_logo, + "logo_rev": logo_rev, })), ) .into_response(), @@ -331,6 +341,8 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa Json(json!({ "setup_required": false, "authenticated": false, + "has_custom_logo": has_custom_logo, + "logo_rev": logo_rev, })), ) .into_response(), diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index cae3de8..c3a80c2 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -18,6 +18,7 @@ pub mod auth; pub mod ipxe_script; pub mod iso_fs; pub mod log_stream; +pub mod notify; pub mod state; pub mod terminal; pub mod uploads; diff --git a/crates/http-api/src/notify.rs b/crates/http-api/src/notify.rs new file mode 100644 index 0000000..973c1e7 --- /dev/null +++ b/crates/http-api/src/notify.rs @@ -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::(); + 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::::relay(&cfg.smtp_host) + .map_err(|e| format!("SMTP relay setup failed: {e}"))? + } else { + AsyncSmtpTransport::::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}")) +} diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 46a92fe..b61e327 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -2,7 +2,7 @@ use crate::uploads::UploadSessions; use crate::auth::SessionStore; use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, - Metrics, SettingsStore, SsoStore, + Metrics, NotifyStore, SettingsStore, SsoStore, }; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use std::sync::Arc; @@ -37,6 +37,10 @@ pub struct AppState { /// SAML SSO configuration. v0.4.5 stores it; the actual SSO login /// flow ships in a later release. 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 /// text format. Cheap to clone (handles to atomics). pub metrics: Metrics, diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 6c17434..6a1c9fa 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -102,6 +102,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let branding = openpxe_core::BrandingStore::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 notify = openpxe_core::NotifyStore::load_or_default(dir.path()); let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); let state = AppState { @@ -115,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { admin, sessions, sso, + notify, metrics, smb: None, smb_shares, @@ -535,6 +537,54 @@ async fn smb_shares_list_starts_empty() { // 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":"ops@example.com","smtp_from":"openpxe@example.com","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] async fn nfs_shares_list_starts_empty() { let (state, _dir) = build_state().await; diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 892bff9..76c3df5 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> { 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 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 metrics = Metrics::new(); @@ -167,6 +168,7 @@ async fn main() -> anyhow::Result<()> { admin: admin.clone(), sessions: sessions.clone(), sso: sso.clone(), + notify: notify.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), smb_shares: smb_shares.clone(), diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 1e04576..a5294e2 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -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 .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 { margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg); } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index bb3132a..b83cbe1 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -973,18 +973,37 @@ } }}, 'Bind MAC to target'); - const rows = hosts.map(h => 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'}, - el('button', {class:'danger', onclick: async () => { - if (!confirm('Remove binding for ' + h.mac + '?')) return; - await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'}); - render('hosts'); - }}, 'Remove')), - ])); + const rows = hosts.map(h => { + // v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole + // table is bound hosts). The button reports its own state inline + // so there's no shared toast to thread through. + const wakeBtn = el('button', {class:'ghost', onclick: async () => { + wakeBtn.disabled = true; + const original = wakeBtn.textContent; + wakeBtn.textContent = 'Waking…'; + try { + const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {}); + 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 ? el('table', {}, [ @@ -1185,9 +1204,8 @@ }, settings: async () => { - const [status, docs, me, sso] = await Promise.all([ + const [status, me, sso] = await Promise.all([ getJSON('/api/status'), - getJSON('/api/docs').catch(() => ({ groups: [] })), getJSON('/api/me').catch(() => ({})), getJSON('/api/sso').catch(() => ({ enabled:false, idp_name:'', metadata:'', metadata_url:'', @@ -1489,10 +1507,133 @@ ]), ]); - // ── API reference (always at the bottom of Settings). - // Sourced from /api/docs so the hand-curated list stays the - // single source of truth and the UI doesn't need its own copy - // baked into the JS bundle. + // v0.5.0: the API reference moved to the Advanced tab; Settings + // now holds just account / SSO / branding. + return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]); + }, + + // 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:'openpxe@example.com', value: notify.smtp_from || ''}); + const sTo = el('input', {type:'text', placeholder:'ops@example.com', 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 apiCard = el('div', {class:'card'}, [ el('header', {}, [ @@ -1515,12 +1656,41 @@ '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 () => { 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('h2', {}, 'OpenPXE'), el('p', {class:'lead'}, @@ -1535,6 +1705,7 @@ el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'}, 'https://openpxe.com/'), ]), + el('div', {style:'margin-top:18px'}, [updBtn, updMsg]), el('p', {class:'msg', style:'margin-top:18px'}, 'iPXE is an internal implementation detail. Everything the firmware ' + 'executes is generated from the settings on these tabs — there is no ' + @@ -1547,6 +1718,46 @@ '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', terminal: 'Terminal', settings: 'Settings', + advanced: 'Advanced', about: 'About', }; @@ -1663,6 +1875,26 @@ let chipsInterval = null; let authScreenEl = 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() { if (authScreenEl && authScreenEl.parentNode) { @@ -1719,10 +1951,7 @@ submit.textContent = 'Sign in'; } }}, [ - el('div', {class:'brand-row'}, [ - el('img', {src:'/assets/logo.svg', alt:''}), - el('div', {class:'name'}, 'OpenPXE'), - ]), + authBrandRow(), 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('label', {class:'field'}, [ @@ -1787,10 +2016,7 @@ submit.textContent = 'Create administrator'; } }}, [ - el('div', {class:'brand-row'}, [ - el('img', {src:'/assets/logo.svg', alt:''}), - el('div', {class:'name'}, 'OpenPXE'), - ]), + authBrandRow(), 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('label', {class:'field'}, [ @@ -1906,15 +2132,17 @@ let me; try { 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) { // /api/me is unauthenticated in every state — if we can't reach // it the server is genuinely down, not an auth problem. document.body.appendChild(el('div', {class:'auth-screen'}, el('div', {class:'auth-card'}, [ - el('div', {class:'brand-row'}, [ - el('img', {src:'/assets/logo.svg', alt:''}), - el('div', {class:'name'}, 'OpenPXE'), - ]), + authBrandRow(), el('h2', {}, 'Connection error'), el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'), ]))); diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 6b91256..6fa32b9 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -53,6 +53,7 @@ Terminal Settings + Advanced About