Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9df3f8bd8 | ||
|
|
fc99973ac3 | ||
|
|
1eb41288c3 |
Generated
+477
-23
@@ -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.68"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1193,7 +1338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.4.68"
|
||||
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.68"
|
||||
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.68"
|
||||
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.68"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.4.68"
|
||||
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.68"
|
||||
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.68"
|
||||
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"
|
||||
|
||||
+7
-2
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.68"
|
||||
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" }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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[..]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
|
||||
# populated at build time — the About-tab update check derives the
|
||||
# Gitea releases API URL from it.
|
||||
repository.workspace = true
|
||||
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
|
||||
|
||||
[lints]
|
||||
@@ -34,6 +38,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"] }
|
||||
|
||||
+333
-59
@@ -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).
|
||||
@@ -215,6 +225,7 @@ async fn index(State(state): State<AppState>) -> Response {
|
||||
&state.public_base_url,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
state.branding.logo_rev(),
|
||||
state.branding.has_logo(),
|
||||
);
|
||||
(
|
||||
[
|
||||
@@ -318,74 +329,75 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
|
||||
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
|
||||
/// GIF) of any aspect ratio; this handler decodes it, draws it
|
||||
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
|
||||
/// That gives the same look as iVentoy regardless of what the operator
|
||||
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
|
||||
/// land in the same place on the boot screen.
|
||||
/// PXE boot-menu background, composed for the iPXE `console --picture`
|
||||
/// call. Returns a full-screen 1024×768 PNG: a dark field with the
|
||||
/// operator's uploaded logo across the top, or — when no logo is set —
|
||||
/// a default OpenPXE mark on the same dark field. Either way the
|
||||
/// endpoint *always* returns a valid PNG so the menu's `console
|
||||
/// --picture` paints a real background instead of falling through to
|
||||
/// bare text (v0.4.69 — replaces the old ASCII wordmark).
|
||||
///
|
||||
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
|
||||
/// than haul in `resvg` we ask the operator to provide a raster when
|
||||
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
|
||||
/// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded
|
||||
/// and transcoded to PNG here, since iPXE only consumes PNG. SVG
|
||||
/// uploads can't be rasterized without hauling in `resvg`, so an SVG
|
||||
/// brand mark falls back to the *default* background for the PXE screen
|
||||
/// (the WebUI still renders the SVG natively in the top-left).
|
||||
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||||
let Some(path) = state.branding.logo_path() else {
|
||||
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
||||
// Resolve the operator's raster upload, if any and if it's a format
|
||||
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
||||
// file) yields `None`, which composes the default background.
|
||||
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
|
||||
(Some(path), Some(mime)) if mime != "image/svg+xml" => {
|
||||
tokio::fs::read(&path).await.ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(mime) = state.branding.logo_mime() else {
|
||||
return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
|
||||
};
|
||||
if mime == "image/svg+xml" {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
|
||||
let composed = match tokio::task::spawn_blocking(move || {
|
||||
openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(png)) => png,
|
||||
Ok(Err(e)) => {
|
||||
// A decode failure on the operator's upload shouldn't blank
|
||||
// the boot screen — fall back to the default background.
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::branding",
|
||||
error = %e, "PXE background compose failed on upload; using default"
|
||||
);
|
||||
match tokio::task::spawn_blocking(|| {
|
||||
openpxe_iso_store::pxe_logo::compose_pxe_background(None)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(png)) => png,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"failed to compose PXE background",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pxe-background task failed: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
|
||||
// centered-top regardless of the operator's source dimensions. The
|
||||
// `image` crate is pure-Rust + sync; offload to a blocking task
|
||||
// because Lanczos resampling on a 4K source can take tens of
|
||||
// milliseconds and we don't want to block the executor.
|
||||
let composed =
|
||||
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(png)) => png,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::branding",
|
||||
error = %e, "failed to compose PXE logo PNG"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to compose PXE logo: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pxe-logo task failed: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
// No-cache so a freshly uploaded logo paints on the next
|
||||
// boot without a stale composite lingering.
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, max-age=0"),
|
||||
),
|
||||
],
|
||||
composed,
|
||||
)
|
||||
@@ -611,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));
|
||||
}
|
||||
}
|
||||
@@ -1245,10 +1268,25 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"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": [
|
||||
@@ -2020,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 ───────────────────────────────────────────────────────
|
||||
|
||||
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
@@ -2079,6 +2331,28 @@ async fn api_metrics(State(state): State<AppState>) -> 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() {
|
||||
// The crate inherits the workspace `repository`, so
|
||||
// CARGO_PKG_REPOSITORY is populated and the update check has a
|
||||
// real URL to hit (regressed once when the inherit was missing).
|
||||
let u = gitea_releases_api_url().expect("repository must be configured at build time");
|
||||
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();
|
||||
|
||||
@@ -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<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() {
|
||||
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<AppState>, 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<AppState>, headers: axum::http::HeaderMa
|
||||
Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
@@ -30,14 +30,15 @@ use std::fmt::Write as _;
|
||||
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
|
||||
/// clients because iPXE normalises the menu primitives across firmwares.
|
||||
///
|
||||
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
|
||||
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
|
||||
/// paint it), a footer carrying version + arch + firmware kind, and an
|
||||
/// optional `console --picture` directive that paints the operator's
|
||||
/// uploaded raster logo on top when the iPXE binary on the wire was
|
||||
/// built with PNG support. The ASCII banner is always rendered so
|
||||
/// even when the picture call no-ops the screen still reads as
|
||||
/// "OpenPXE — here is the menu" rather than a featureless box.
|
||||
/// v0.4.69: rendered with an iVentoy-style graphical frame — a
|
||||
/// `console --picture` directive paints a full-screen PNG background
|
||||
/// (the operator's uploaded logo on a dark field, or the default
|
||||
/// OpenPXE mark) with the menu text overlaid below a reserved top
|
||||
/// margin, plus a footer carrying version + arch + firmware kind. On
|
||||
/// iPXE binaries built with `IMAGE_PNG` + `CONSOLE_FRAMEBUFFER` (our
|
||||
/// x86_64 UEFI binaries, compiled from source) the background paints;
|
||||
/// on binaries without PNG support the `|| console` fallback yields a
|
||||
/// clean text menu. The old ASCII wordmark has been removed.
|
||||
#[must_use]
|
||||
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -56,14 +57,20 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
let _ = writeln!(s, "set esc:hex 1b");
|
||||
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
|
||||
// v0.4.6: best-effort graphics console with the operator-uploaded
|
||||
// raster logo. Falls back to plain text console on iPXE builds
|
||||
// without PNG support — the `||` chain keeps a parse-clean
|
||||
// single-statement form so even the strictest iPXE parsers accept
|
||||
// it. The `console` reset at the end re-syncs the menu output.
|
||||
// v0.4.69: graphical background. `/branding/pxe-logo` always
|
||||
// returns a full-screen 1024×768 PNG now — the operator's logo on a
|
||||
// dark field, or a default OpenPXE mark when none is uploaded. The
|
||||
// `--top 290` reserves the top band (where the logo paints) so the
|
||||
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` +
|
||||
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
|
||||
// — see deploy/docker/Dockerfile) this paints the background and
|
||||
// overlays the menu. On a build *without* PNG support (the fetched
|
||||
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
|
||||
// fails and the `|| console` resets to a clean full-screen text
|
||||
// menu. Either way there's no ASCII placeholder anymore.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"console --picture {base}/branding/pxe-logo || console"
|
||||
"console --picture {base}/branding/pxe-logo --top 290 || console"
|
||||
);
|
||||
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
||||
@@ -77,19 +84,8 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
);
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
||||
// ASCII OpenPXE wordmark. Works on every iPXE build, including
|
||||
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
|
||||
// so `console --picture` paints nothing). When the queued iPXE
|
||||
// source-build lands and the operator's uploaded raster actually
|
||||
// paints via `console --picture`, this banner can be retired in
|
||||
// favour of the real image. The compositor at
|
||||
// /branding/pxe-logo is already wired and waiting.
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
|
||||
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
|
||||
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
|
||||
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
|
||||
let _ = writeln!(s, "item --gap -- |_|");
|
||||
// v0.4.69: the ASCII wordmark is gone — the graphical background
|
||||
// (set via `console --picture` above) carries the branding now.
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
@@ -631,27 +627,29 @@ mod password_tests {
|
||||
|
||||
#[test]
|
||||
fn top_menu_has_polished_branding_and_arch_footer() {
|
||||
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
|
||||
// `console --picture` line that PNG-capable iPXE builds will
|
||||
// honour (queued for a follow-up release once we can rebuild
|
||||
// iPXE from source on native x86_64 hardware), an ASCII
|
||||
// OpenPXE wordmark that works on every iPXE build (including
|
||||
// the boot.ipxe.org pre-builds we currently ship), and a
|
||||
// single-line footer carrying the OpenPXE version + arch.
|
||||
// v0.4.69: the menu emits a `console --picture` line that paints
|
||||
// a full-screen PNG background (the operator's logo, or the
|
||||
// default OpenPXE mark) reserving a top margin for it, then
|
||||
// falls back to a clean text console on iPXE builds without PNG
|
||||
// support. The ASCII wordmark is gone — the graphical
|
||||
// background carries the branding now. A single-line footer
|
||||
// still carries the OpenPXE version + arch.
|
||||
let settings = Settings::default();
|
||||
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
||||
assert!(
|
||||
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
|
||||
"missing console --picture line:\n{s}"
|
||||
);
|
||||
// The picture call reserves a top margin for the logo band.
|
||||
assert!(s.contains("--top 290"), "missing --top margin:\n{s}");
|
||||
// Picture-or-text-console must be a single statement so older
|
||||
// iPXE parsers don't choke on the chain.
|
||||
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
||||
// ASCII wordmark — paints on every iPXE build regardless of
|
||||
// PNG support.
|
||||
// The ASCII wordmark must be GONE — its removal is the whole
|
||||
// point of v0.4.69's graphical background.
|
||||
assert!(
|
||||
s.contains("___ ___ __ __ ___"),
|
||||
"ASCII banner missing first row:\n{s}"
|
||||
!s.contains("___ ___ __ __ ___"),
|
||||
"ASCII banner should have been removed:\n{s}"
|
||||
);
|
||||
// Footer with version + arch interpolation. The version comes
|
||||
// from CARGO_PKG_VERSION at compile time.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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":"[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]
|
||||
async fn nfs_shares_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
@@ -1832,13 +1882,39 @@ async fn docs_lists_new_v0_4_5_endpoints() {
|
||||
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_404_when_no_custom_logo_configured() {
|
||||
async fn pxe_background_serves_default_when_no_custom_logo() {
|
||||
// v0.4.69: the endpoint always returns a full-screen PNG background
|
||||
// now — when no custom logo is configured it composes the default
|
||||
// OpenPXE mark on a dark field rather than 404ing. This is what lets
|
||||
// the PXE menu's `console --picture` paint a real background instead
|
||||
// of falling back to the (now-removed) ASCII placeholder.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("no custom logo"), "got: {text}");
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/branding/pxe-logo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"image/png"
|
||||
);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(body.starts_with(b"\x89PNG"), "default background not a PNG");
|
||||
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||
assert_eq!(width, 1024, "default background should be 1024 wide");
|
||||
}
|
||||
|
||||
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
|
||||
@@ -1857,10 +1933,11 @@ fn tiny_png() -> Vec<u8> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
||||
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
||||
// light up the PXE menu's `console --picture` overlay — the menu
|
||||
// simply paints without a logo.
|
||||
async fn pxe_background_falls_back_to_default_for_svg_upload() {
|
||||
// iPXE can't rasterize SVG, so an SVG upload doesn't paint as the
|
||||
// PXE background — but v0.4.69 still returns the *default* OpenPXE
|
||||
// background PNG (not a 404) so the boot screen stays graphical.
|
||||
// The WebUI top-left continues to render the SVG natively.
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.branding
|
||||
@@ -1871,10 +1948,23 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("SVG"), "got: {text}");
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/branding/pxe-logo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG");
|
||||
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||
assert_eq!(width, 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -66,6 +66,7 @@ use nfs3_types::nfs3::{
|
||||
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
|
||||
Nfs3Result, READ3args, READDIR3args,
|
||||
};
|
||||
use nfs3_types::rpc::{auth_unix, opaque_auth};
|
||||
use nfs3_types::xdr_codec::Opaque;
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
@@ -741,9 +742,28 @@ async fn connect_once(
|
||||
nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>,
|
||||
NfsClientError,
|
||||
> {
|
||||
// v0.4.69: present an AUTH_SYS (AUTH_UNIX) credential instead of
|
||||
// the crate default (AUTH_NONE). This is the fix for the
|
||||
// `NFS3ERR_ACCES` operators hit *after* the v0.4.68 privileged-port
|
||||
// fix got them past the mount: nearly every NFS server exports
|
||||
// `sec=sys` and rejects AUTH_NONE callers on the actual file ops
|
||||
// (READDIR/LOOKUP/READ) even when MOUNT succeeded. We send uid 0 /
|
||||
// gid 0 — a server with `no_root_squash` treats us as root (full
|
||||
// read), and the far more common `root_squash` maps us to the
|
||||
// anonymous user, which can still read any world-readable ISO
|
||||
// share (the normal case). We deliberately keep this fixed rather
|
||||
// than a UI knob: ISO libraries are read-only shared data, and a
|
||||
// uid field is exactly the kind of thing that makes a "dead simple"
|
||||
// tool confusing for an L1 tech.
|
||||
let cred = opaque_auth::auth_unix(&auth_unix {
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let fut = Nfs3ConnectionBuilder::new(TokioConnector, server, export)
|
||||
.connect_from_privileged_port(privileged)
|
||||
.nfs3_port(port)
|
||||
.credential(cred)
|
||||
.mount();
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, fut).await {
|
||||
Ok(Ok(conn)) => Ok(conn),
|
||||
@@ -839,10 +859,19 @@ fn hint_for(text: &str) -> Option<String> {
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nfs3err_acces") || s.contains("permission denied") {
|
||||
// The mount succeeded but a file op was denied. OpenPXE
|
||||
// already presents an AUTH_SYS uid-0 credential, so this is a
|
||||
// server-side permission/squash issue, not an IP or auth-flavor
|
||||
// one. Point the operator at the share's filesystem permissions.
|
||||
Some(
|
||||
"the NFS server rejected this client. Most likely your export \
|
||||
is restricted by client IP — add this OpenPXE host (or its \
|
||||
subnet) to the export's allowed-clients list on the server."
|
||||
"the mount succeeded but the server denied reading the share \
|
||||
(NFS3ERR_ACCES). OpenPXE connects as AUTH_SYS uid 0, so this is \
|
||||
a server-side permission issue: make sure the export's \
|
||||
directory is readable (most ISO shares are world-readable / \
|
||||
0755), and that the export isn't restricted to a specific \
|
||||
non-root user via all_squash/anonuid. On UniFi UNAS, confirm \
|
||||
the share's NFS permission for this host is Read-Write (or \
|
||||
Read-Only) and that the share itself grants read access."
|
||||
.into(),
|
||||
)
|
||||
} else if s.contains("nfs3err_noent")
|
||||
|
||||
@@ -1,86 +1,179 @@
|
||||
//! Operator-logo compositor for the iPXE menu.
|
||||
//! PXE boot-menu background compositor.
|
||||
//!
|
||||
//! The brief: match iVentoy's polished centered-logo PXE chrome with
|
||||
//! whatever raster the operator drops onto Settings → Branding. A wide
|
||||
//! wordmark, a portrait stack, a square monogram — all three should
|
||||
//! land in roughly the same place on the boot screen.
|
||||
//! The brief (v0.4.69): match iVentoy's polished graphical PXE screen.
|
||||
//! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to
|
||||
//! the framebuffer via `console --picture`, then draws the text menu on
|
||||
//! top (the console's default background colour is rendered transparent
|
||||
//! so the picture shows through the menu's blank cells). So what we
|
||||
//! produce here is a **full-screen 1024×768 background**, not just a
|
||||
//! floating logo:
|
||||
//!
|
||||
//! Approach: decode the operator's upload, fit it into a fixed
|
||||
//! 1024×768 canvas with the logo horizontally centered and pinned a
|
||||
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
|
||||
//! built with `IMAGE_PNG` paints the result via `console --picture`.
|
||||
//! - a solid dark field (matches the WebUI dark theme so the product
|
||||
//! feels consistent from browser to bare metal), with
|
||||
//! - the operator's uploaded logo composited across the top, leaving
|
||||
//! the lower ~two-thirds clear for the iPXE menu text.
|
||||
//!
|
||||
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
|
||||
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
|
||||
//! correctly downscaled; tiny icons get drawn at their native size,
|
||||
//! centered, with transparent margins.
|
||||
//! When no custom logo is uploaded we still return a designed
|
||||
//! background — a dark field with a centered "rainbow-horizon" disc
|
||||
//! echoing the bundled OpenPXE mark — so the boot screen is graphical
|
||||
//! out of the box. This replaces the old ASCII wordmark entirely.
|
||||
//!
|
||||
//! We deliberately don't ship `resvg` for SVG support — keeping the
|
||||
//! dependency surface narrow matters more than supporting SVG-only
|
||||
//! brand assets. The WebUI's logo stays SVG-native (the browser
|
||||
//! rasterizes it); the PXE menu wants a raster regardless.
|
||||
//! iPXE does **not** scale pictures (confirmed against the decoder
|
||||
//! source): the image is painted at native pixel size and the firmware
|
||||
//! picks the smallest video mode that fits. 1024×768 is the universal
|
||||
//! safe mode, so we pin the canvas there. Operators uploading a 4K logo
|
||||
//! get it downscaled to fit the top band; tiny icons paint at native
|
||||
//! size, centered.
|
||||
//!
|
||||
//! Input formats: anything the `image` crate decodes with our enabled
|
||||
//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so
|
||||
//! we always *emit* PNG regardless of what the operator uploaded; a
|
||||
//! WebP logo is transcoded here transparently.
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Canvas dimensions used for the composed PXE logo. Picked to match
|
||||
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
|
||||
/// gives a 1:1 paint with no scaling at the firmware layer.
|
||||
/// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer
|
||||
/// mode every BIOS/UEFI console supports, and iPXE doesn't scale.
|
||||
pub const CANVAS_W: u32 = 1024;
|
||||
pub const CANVAS_H: u32 = 768;
|
||||
|
||||
/// Maximum dimensions for the operator's logo inside the canvas. Any
|
||||
/// upload larger than this in either axis is downscaled (preserving
|
||||
/// aspect ratio) to fit. Smaller uploads paint at native size.
|
||||
const LOGO_MAX_W: u32 = 600;
|
||||
/// Bounding box for the operator's logo across the top band. Wider than
|
||||
/// the old floating-logo box because the logo now anchors a full
|
||||
/// background rather than sitting alone on transparency.
|
||||
const LOGO_MAX_W: u32 = 760;
|
||||
const LOGO_MAX_H: u32 = 200;
|
||||
|
||||
/// Top margin in pixels from the canvas's top edge to the logo's top
|
||||
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
|
||||
/// menu below).
|
||||
const LOGO_TOP_MARGIN: u32 = 64;
|
||||
/// Top margin from the canvas top to the logo's top edge.
|
||||
const LOGO_TOP_MARGIN: u32 = 72;
|
||||
|
||||
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
|
||||
/// 1024×768 PNG and return the encoded bytes.
|
||||
/// Background fill — a near-black with a faint blue cast, matching the
|
||||
/// WebUI's dark theme surface so the product reads as one piece from
|
||||
/// browser to PXE screen.
|
||||
const BG: Rgba<u8> = Rgba([11, 14, 22, 255]);
|
||||
|
||||
/// Compose the operator's uploaded raster (`Some`) — or the default
|
||||
/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background
|
||||
/// and return the encoded bytes.
|
||||
///
|
||||
/// Errors when the source can't be decoded or the encoded buffer can't
|
||||
/// be written (only really fires on out-of-memory; the encoder itself
|
||||
/// is infallible for well-formed inputs).
|
||||
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
let logo = image::load_from_memory(src_bytes)?;
|
||||
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
|
||||
// keeps the antialiasing crisp on the framebuffer console; it's a
|
||||
// touch slower than `Triangle` but the operator hits this endpoint
|
||||
// once per boot at most.
|
||||
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
|
||||
let logo_rgba = logo.to_rgba8();
|
||||
/// Errors only when a provided `src_bytes` can't be decoded; the
|
||||
/// `None` path and the PNG encode are infallible for our fixed canvas.
|
||||
pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result<Vec<u8>, ImageError> {
|
||||
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);
|
||||
|
||||
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
|
||||
// on framebuffer consoles; older builds simply draw the alpha as
|
||||
// black, which still gives a sensible look.
|
||||
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
|
||||
let logo_w = logo_rgba.width();
|
||||
let logo_h = logo_rgba.height();
|
||||
// Horizontal center, top-margin from the top. Saturating math
|
||||
// means a logo wider than CANVAS_W (shouldn't happen after the
|
||||
// downscale above, but defensive) just sits flush-left.
|
||||
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
|
||||
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
|
||||
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
|
||||
match src_bytes {
|
||||
Some(bytes) => {
|
||||
let logo = image::load_from_memory(bytes)?;
|
||||
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
|
||||
let logo_rgba = logo.to_rgba8();
|
||||
let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2;
|
||||
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height()));
|
||||
// `overlay` alpha-composites, so a transparent-background
|
||||
// logo blends onto the dark field exactly as designed.
|
||||
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
|
||||
}
|
||||
None => draw_default_mark(&mut canvas),
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(64 * 1024);
|
||||
let mut out = Vec::with_capacity(128 * 1024);
|
||||
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Back-compat shim for the old name — callers that pass a raw logo and
|
||||
/// want it composited get the same result as `compose_pxe_background`
|
||||
/// with `Some`.
|
||||
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
compose_pxe_background(Some(src_bytes))
|
||||
}
|
||||
|
||||
/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the
|
||||
/// default brand mark when no operator logo is set. Pure pixel math —
|
||||
/// no font, no SVG rasterizer, no extra deps. A filled circle with a
|
||||
/// left-to-right hue sweep echoes the bundled `logo.svg` motif.
|
||||
// Casts here are all bounded small-range geometry (radius ≤ 90, canvas
|
||||
// ≤ 1024) — precision loss / wrap is structurally impossible.
|
||||
#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
fn draw_default_mark(canvas: &mut RgbaImage) {
|
||||
let radius: i32 = 90;
|
||||
let cx = (CANVAS_W / 2) as i32;
|
||||
let cy = (LOGO_TOP_MARGIN + 100) as i32;
|
||||
// Four-stop horizontal sweep across the disc (teal → blue → violet
|
||||
// → magenta) — the OpenPXE palette.
|
||||
let stops = [
|
||||
[0x22u8, 0xd3, 0xaa],
|
||||
[0x3b, 0x82, 0xf6],
|
||||
[0x8b, 0x5c, 0xf6],
|
||||
[0xec, 0x48, 0x99],
|
||||
];
|
||||
let r2 = radius * radius;
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
if dx * dx + dy * dy > r2 {
|
||||
continue;
|
||||
}
|
||||
// Position across the disc in [0,1] left→right.
|
||||
let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0)))
|
||||
/ (f32::from(i16::try_from(2 * radius).unwrap_or(1)));
|
||||
let color = gradient_at(&stops, t);
|
||||
// Soft edge: fade alpha in the outer 3px ring.
|
||||
let dist = ((dx * dx + dy * dy) as f32).sqrt();
|
||||
let alpha = if dist > (radius as f32 - 3.0) {
|
||||
let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0;
|
||||
(edge * 255.0) as u8
|
||||
} else {
|
||||
255
|
||||
};
|
||||
let px = cx + dx;
|
||||
let py = cy + dy;
|
||||
if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H {
|
||||
blend_pixel(canvas, px as u32, py as u32, color, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear interpolate across an N-stop palette at position `t` in [0,1].
|
||||
// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let segments = stops.len() - 1;
|
||||
let scaled = t * segments as f32;
|
||||
let idx = (scaled.floor() as usize).min(segments - 1);
|
||||
let frac = scaled - idx as f32;
|
||||
let a = stops[idx];
|
||||
let b = stops[idx + 1];
|
||||
[
|
||||
lerp(a[0], b[0], frac),
|
||||
lerp(a[1], b[1], frac),
|
||||
lerp(a[2], b[2], frac),
|
||||
]
|
||||
}
|
||||
|
||||
fn lerp(a: u8, b: u8, t: f32) -> u8 {
|
||||
(f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8
|
||||
}
|
||||
|
||||
/// Alpha-blend `color` at `alpha` over the existing canvas pixel.
|
||||
fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) {
|
||||
let bg = canvas.get_pixel(x, y).0;
|
||||
let a = f32::from(alpha) / 255.0;
|
||||
let out = Rgba([
|
||||
lerp(bg[0], color[0], a),
|
||||
lerp(bg[1], color[1], a),
|
||||
lerp(bg[2], color[2], a),
|
||||
255,
|
||||
]);
|
||||
canvas.put_pixel(x, y, out);
|
||||
}
|
||||
|
||||
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w <= max_w && h <= max_h {
|
||||
return img;
|
||||
}
|
||||
// Preserve aspect ratio. `resize` clamps to the smaller of the
|
||||
// two scale factors so we never overshoot the bounding box.
|
||||
img.resize(max_w, max_h, FilterType::Lanczos3)
|
||||
}
|
||||
|
||||
@@ -99,54 +192,73 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_emits_canvas_sized_png() {
|
||||
fn custom_logo_emits_canvas_sized_png_with_dark_field() {
|
||||
let src = solid_png(120, 60, [200, 50, 50]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
// Round-trip the output and confirm dimensions.
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
let out = compose_pxe_background(Some(&src)).unwrap();
|
||||
let img = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||
assert_eq!(img.width(), CANVAS_W);
|
||||
assert_eq!(img.height(), CANVAS_H);
|
||||
// A far corner should be the opaque dark background fill, not
|
||||
// transparent — this is a full background now, not a floating
|
||||
// logo on transparency.
|
||||
let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
|
||||
assert_eq!(corner.0, BG.0, "corner should be the dark fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_logo_centered_at_top_margin() {
|
||||
fn custom_logo_painted_in_top_band() {
|
||||
let src = solid_png(100, 40, [10, 200, 10]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
let out = compose_pxe_background(Some(&src)).unwrap();
|
||||
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||
// Pixel just inside the logo box should match the source color
|
||||
// (alpha=255). Pixel near a far corner of the canvas should be
|
||||
// the transparent background.
|
||||
let cx = (CANVAS_W - 100) / 2;
|
||||
let cy = LOGO_TOP_MARGIN;
|
||||
let inside = canvas.get_pixel(cx + 10, cy + 10);
|
||||
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
|
||||
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
|
||||
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
|
||||
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
|
||||
assert!(
|
||||
inside.0[1] > 100 && inside.0[0] < 100,
|
||||
"logo pixel color mismatch: {inside:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_logo_is_downscaled_to_bounding_box() {
|
||||
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
|
||||
// both axes. After downscale the output must fit; we re-decode
|
||||
// the canvas, count non-transparent pixels, and confirm none
|
||||
// sit outside the expected band.
|
||||
let src = solid_png(4000, 800, [50, 50, 200]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
fn default_background_is_dark_with_a_painted_mark() {
|
||||
let out = compose_pxe_background(None).unwrap();
|
||||
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||
// Span row at the top margin should have non-transparent
|
||||
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
|
||||
// should be entirely transparent.
|
||||
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
|
||||
for x in 0..CANVAS_W {
|
||||
let p = canvas.get_pixel(x, bottom_band_y);
|
||||
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
|
||||
}
|
||||
assert_eq!(canvas.width(), CANVAS_W);
|
||||
assert_eq!(canvas.height(), CANVAS_H);
|
||||
// Corner is dark fill.
|
||||
assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0);
|
||||
// Center of the disc is not the background fill (something was
|
||||
// painted there).
|
||||
let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100);
|
||||
assert_ne!(center.0, BG.0, "default mark should paint over the field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() {
|
||||
// Encode a JPEG and confirm the compositor decodes it and emits
|
||||
// a valid PNG (iPXE only eats PNG, so transcoding is the point).
|
||||
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90]));
|
||||
let mut jpeg = Vec::new();
|
||||
DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg)
|
||||
.unwrap();
|
||||
let out = compose_pxe_background(Some(&jpeg)).unwrap();
|
||||
// Output must be a PNG (magic bytes) of canvas size.
|
||||
assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n");
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(img.width(), CANVAS_W);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_bytes_returns_error_not_panic() {
|
||||
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
|
||||
let r = compose_pxe_background(Some(b"\xde\xad\xbe\xef not an image"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_endpoints_match_stops() {
|
||||
let stops = [[0, 0, 0], [255, 255, 255]];
|
||||
assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]);
|
||||
assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -117,6 +117,20 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* v0.4.69: FleetDM-style full-width custom logo. When the operator has
|
||||
uploaded a custom brand mark, the sidebar header drops the bundled
|
||||
26px mark + "OpenPXE" wordmark and instead lets the uploaded image
|
||||
span the header — left-aligned, capped at 200x50, scaled to fit
|
||||
without distortion. The wordmark is hidden so the operator's logo is
|
||||
the sole brand element (their logo presumably already contains their
|
||||
name). The bundled-default case keeps the mark + wordmark. */
|
||||
.sidebar .brand.has-custom-logo { gap: 0; }
|
||||
.sidebar .brand.has-custom-logo img {
|
||||
width: auto; height: 50px; max-width: 200px;
|
||||
object-fit: contain; object-position: left center; flex: none;
|
||||
}
|
||||
.sidebar .brand.has-custom-logo strong { display: none; }
|
||||
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
||||
.sidebar nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
@@ -613,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);
|
||||
}
|
||||
|
||||
+260
-32
@@ -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:'[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 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.'),
|
||||
])));
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="{{BRAND_CLASS}}">
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
|
||||
<strong>OpenPXE</strong>
|
||||
</div>
|
||||
@@ -53,6 +53,7 @@
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="settings">Settings</a>
|
||||
<a data-view="advanced">Advanced</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
|
||||
+16
-1
@@ -24,12 +24,27 @@
|
||||
/// release version (which only changes on upgrade). `index.html`
|
||||
/// itself is served `no-cache`, so the fresh token lands as soon as
|
||||
/// the operator reloads after an upload.
|
||||
/// * `has_custom_logo` switches the sidebar brand block between the
|
||||
/// bundled mark + "OpenPXE" wordmark (false) and a FleetDM-style
|
||||
/// full-width custom logo with the wordmark hidden (true). Rendered
|
||||
/// server-side so there's no flash of the default mark before JS runs.
|
||||
#[must_use]
|
||||
pub fn index_html(base_url: &str, asset_version: &str, logo_rev: u64) -> String {
|
||||
pub fn index_html(
|
||||
base_url: &str,
|
||||
asset_version: &str,
|
||||
logo_rev: u64,
|
||||
has_custom_logo: bool,
|
||||
) -> String {
|
||||
let brand_class = if has_custom_logo {
|
||||
"brand has-custom-logo"
|
||||
} else {
|
||||
"brand"
|
||||
};
|
||||
INDEX_HTML
|
||||
.replace("{{BASE_URL}}", base_url)
|
||||
.replace("{{ASSET_VERSION}}", asset_version)
|
||||
.replace("{{LOGO_REV}}", &logo_rev.to_string())
|
||||
.replace("{{BRAND_CLASS}}", brand_class)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -17,14 +17,11 @@
|
||||
ARG RUST_VERSION=1.95
|
||||
|
||||
########## fetch iPXE binaries + wimboot ##########
|
||||
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We
|
||||
# want PNG support (so `console --picture` paints the operator's logo
|
||||
# on the PXE menu) but the obvious path — adding a new `ipxe-build`
|
||||
# stage that compiles iPXE from source with `IMAGE_PNG` enabled —
|
||||
# runs into a QEMU/gcc instability when cross-emulating x86_64 on
|
||||
# arm64 build hosts (intermittent `cc1` segfaults). The compositor
|
||||
# at /branding/pxe-logo is already wired so when the iPXE rebuild
|
||||
# lands (on native x86_64 hardware), no other code change is needed.
|
||||
# Pulls the upstream boot.ipxe.org pre-builds (no PNG support) plus
|
||||
# wimboot. These cover the arches we don't build from source here:
|
||||
# BIOS undionly.kpxe and i386-efi (which need a 32-bit x86 toolchain),
|
||||
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
|
||||
# binaries from the `ipxe-build` stage overlay on top of.
|
||||
FROM debian:12-slim AS fetch
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -32,6 +29,36 @@ WORKDIR /src
|
||||
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
|
||||
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
|
||||
|
||||
########## build PNG-enabled iPXE from source ##########
|
||||
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
|
||||
# background on the PXE screen using stock iPXE built with
|
||||
# CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public iPXE
|
||||
# binaries omit those, so `console --picture` is a no-op on them.
|
||||
# We build our own from upstream with that thin config delta.
|
||||
#
|
||||
# The historical blocker was cc1 segfaulting when an amd64 gcc ran
|
||||
# under QEMU emulation on an arm64 host. The fix: pin this stage to
|
||||
# $BUILDPLATFORM (the NATIVE builder arch — arm64 on an Apple-Silicon
|
||||
# Mac, amd64 in x86 CI) and cross-compile with a real cross toolchain
|
||||
# (CROSS_COMPILE=x86_64-linux-gnu-). The compiler runs native and
|
||||
# emits x86_64 — no emulation, no segfault. arm64-efi builds natively.
|
||||
FROM --platform=$BUILDPLATFORM debian:12-slim AS ipxe-build
|
||||
# libc6-dev is REQUIRED and easy to miss under --no-install-recommends:
|
||||
# iPXE's host utilities (elf2efi, zbin) compile with the native gcc and
|
||||
# pull <stdint.h>; without the native libc headers gcc's #include_next
|
||||
# falls through to iPXE's freestanding headers and dies on bits/stdint.h.
|
||||
# The target (iPXE firmware) code is -ffreestanding/-nostdinc, so the
|
||||
# x86_64 cross toolchain needs NO cross libc headers.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git make perl gcc binutils libc6-dev \
|
||||
gcc-x86-64-linux-gnu binutils-x86-64-linux-gnu \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
COPY scripts/build-ipxe.sh scripts/build-ipxe.sh
|
||||
COPY deploy/ipxe/local/ deploy/ipxe/local/
|
||||
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
|
||||
|
||||
########## build openpxe ##########
|
||||
FROM rust:${RUST_VERSION}-bookworm AS build
|
||||
WORKDIR /src
|
||||
@@ -64,7 +91,14 @@ RUN apt-get update \
|
||||
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ crates/
|
||||
# Baseline binaries (BIOS / i386 / wimboot), then overlay the
|
||||
# PNG-enabled x86_64 + arm64 UEFI binaries built from source. The
|
||||
# overlay wins for snponly.efi / ipxe.efi / snponly-arm64.efi so the
|
||||
# common modern clients get the graphical background; the rest keep the
|
||||
# upstream no-PNG binaries and the menu's `|| console` text fallback.
|
||||
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
|
||||
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
|
||||
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
|
||||
|
||||
# Cache cargo registry + target across builds. The mtime touch is
|
||||
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* OpenPXE iPXE build override — console options.
|
||||
*
|
||||
* Included at the end of config/console.h. CONSOLE_FRAMEBUFFER is the
|
||||
* unified graphical framebuffer console (EFI GOP on UEFI, VESA on
|
||||
* BIOS); it's what `console --picture` paints into. This is the same
|
||||
* single flag iVentoy enables for its graphical PXE screen.
|
||||
*
|
||||
* We *add* the framebuffer console rather than replacing the default
|
||||
* EFI/BIOS text consoles, so text output still works before/after the
|
||||
* picture is set.
|
||||
*/
|
||||
|
||||
#define CONSOLE_FRAMEBUFFER
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* OpenPXE iPXE build override — general options.
|
||||
*
|
||||
* iPXE includes <config/local/general.h> at the end of config/general.h,
|
||||
* so anything defined here is layered on top of the stock defaults
|
||||
* without editing upstream files. We enable exactly the features the
|
||||
* graphical PXE boot menu needs:
|
||||
*
|
||||
* IMAGE_PNG - PNG decoder, so `console --picture <png>` can paint
|
||||
* the operator's logo / OpenPXE background.
|
||||
* IMAGE_PNM - Netpbm decoder (cheap; harmless belt-and-suspenders).
|
||||
* CONSOLE_CMD - the `console` command itself. Without it you get
|
||||
* "console: command not found" even with a framebuffer.
|
||||
*
|
||||
* (CONSOLE_FRAMEBUFFER lives in config/local/console.h.)
|
||||
*
|
||||
* Everything else stays at upstream defaults — we are intentionally a
|
||||
* thin, auditable delta over stock iPXE so the UBDL/GPL story is simple.
|
||||
*/
|
||||
|
||||
#define IMAGE_PNG
|
||||
#define IMAGE_PNM
|
||||
#define CONSOLE_CMD
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build PNG-enabled iPXE binaries from source.
|
||||
#
|
||||
# Why from source: the official boot.ipxe.org binaries (and the
|
||||
# Debian-packaged ones) are NOT built with CONSOLE_FRAMEBUFFER +
|
||||
# IMAGE_PNG + CONSOLE_CMD, so `console --picture` is a no-op on them —
|
||||
# you can't paint a graphical boot-menu background. iVentoy solves this
|
||||
# by shipping its own iPXE build with exactly those three flags; we do
|
||||
# the same, from upstream iPXE, with a thin auditable config delta
|
||||
# (deploy/ipxe/local/{general,console}.h).
|
||||
#
|
||||
# Why a real cross-compiler instead of QEMU: building amd64 iPXE by
|
||||
# emulating an amd64 gcc under QEMU on an arm64 host intermittently
|
||||
# segfaults cc1 (the reason this was stuck for ~8 releases). Running a
|
||||
# NATIVE arm64 gcc that cross-targets x86_64 (CROSS_COMPILE=
|
||||
# x86_64-linux-gnu-) sidesteps emulation entirely — the compiler is a
|
||||
# native binary, it just emits x86_64 objects. This stage is meant to
|
||||
# run on $BUILDPLATFORM (the native builder arch), NOT the emulated
|
||||
# target platform.
|
||||
#
|
||||
# Outputs (into $DEST), using the filenames OpenPXE's arch mapping
|
||||
# expects:
|
||||
# snponly.efi x86_64 UEFI, PNG-enabled
|
||||
# ipxe.efi x86_64 UEFI, PNG-enabled (bundled drivers)
|
||||
#
|
||||
# We build ONLY x86_64 UEFI, always via the x86_64 cross toolchain
|
||||
# (`x86_64-linux-gnu-gcc`). That's deliberately host-arch-agnostic: it
|
||||
# works whether this stage runs on an arm64 Mac builder or an amd64 CI
|
||||
# runner, because the cross compiler runs native and emits x86_64
|
||||
# either way. Building arm64-efi or BIOS here would re-introduce a
|
||||
# dependency on the host arch (native arm64 build) or a 32-bit multilib
|
||||
# toolchain — so those arches keep their upstream-fetched (no-PNG)
|
||||
# binaries and fall back to the menu's clean `|| console` text screen.
|
||||
# Modern PXE clients are overwhelmingly x86_64 UEFI, which get the full
|
||||
# graphical background.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEST="${1:-$ROOT/assets/ipxe}"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
|
||||
# keeps builds reproducible and protects against a transient master
|
||||
# breakage. Bump deliberately.
|
||||
IPXE_REPO="https://github.com/ipxe/ipxe.git"
|
||||
IPXE_REF="${IPXE_REF:-master}"
|
||||
|
||||
echo ">> cloning iPXE ($IPXE_REF)"
|
||||
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|
||||
|| git clone "$IPXE_REPO" "$WORK/ipxe"
|
||||
SRC="$WORK/ipxe/src"
|
||||
|
||||
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
||||
mkdir -p "$SRC/config/local"
|
||||
cp "$ROOT/deploy/ipxe/local/general.h" "$SRC/config/local/general.h"
|
||||
cp "$ROOT/deploy/ipxe/local/console.h" "$SRC/config/local/console.h"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# x86_64 UEFI — cross-compiled with the native arm64 gcc targeting
|
||||
# x86_64. HOST_CC stays the native cc for iPXE's build-time utilities
|
||||
# (elf2efi, zbin, …); only the target objects use the cross compiler.
|
||||
echo ">> building x86_64 UEFI (snponly.efi, ipxe.efi)"
|
||||
make -C "$SRC" -j"$(nproc)" \
|
||||
CROSS_COMPILE=x86_64-linux-gnu- \
|
||||
bin-x86_64-efi/snponly.efi \
|
||||
bin-x86_64-efi/ipxe.efi
|
||||
cp "$SRC/bin-x86_64-efi/snponly.efi" "$DEST/snponly.efi"
|
||||
cp "$SRC/bin-x86_64-efi/ipxe.efi" "$DEST/ipxe.efi"
|
||||
|
||||
echo ">> iPXE build complete:"
|
||||
ls -l "$DEST"/snponly.efi "$DEST"/ipxe.efi
|
||||
Reference in New Issue
Block a user