Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9df3f8bd8 | ||
|
|
fc99973ac3 | ||
|
|
1eb41288c3 | ||
|
|
2f12a2ae84 | ||
|
|
3f9d8568f0 | ||
|
|
9f66c269c4 | ||
|
|
900b65b3ec | ||
|
|
07e7c18698 | ||
|
|
761489761c | ||
|
|
0afbe860e8 | ||
|
|
9f694f7c79 |
Generated
+511
-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"
|
||||
@@ -1102,6 +1238,46 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_client"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ac227029a6127f3474b4fb61cbf2d3dddbe9424ad50c24fada68b633687a532"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"nfs3_types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nfs3_types"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94"
|
||||
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"
|
||||
@@ -1140,7 +1316,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.4.62"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1162,7 +1338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.4.62"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -1170,7 +1346,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
@@ -1181,21 +1357,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.4.62"
|
||||
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.62"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1203,6 +1379,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"image",
|
||||
"lettre",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"openpxe-core",
|
||||
@@ -1210,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",
|
||||
@@ -1226,31 +1404,34 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.4.62"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.4.62"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"futures",
|
||||
"hex",
|
||||
"image",
|
||||
"libc",
|
||||
"nfs3_client",
|
||||
"nfs3_types",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1260,21 +1441,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.4.62"
|
||||
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.62"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
@@ -1379,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"
|
||||
@@ -1388,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"
|
||||
@@ -1401,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]]
|
||||
@@ -1412,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]]
|
||||
@@ -1424,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"
|
||||
@@ -1450,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"
|
||||
@@ -1485,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"
|
||||
@@ -1498,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"
|
||||
@@ -1626,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"
|
||||
@@ -1725,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"
|
||||
@@ -1756,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]]
|
||||
@@ -1770,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"
|
||||
@@ -1863,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"
|
||||
@@ -1960,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",
|
||||
@@ -2074,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"
|
||||
@@ -2121,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"
|
||||
@@ -2179,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"
|
||||
@@ -2216,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"
|
||||
@@ -2270,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"
|
||||
@@ -2282,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"
|
||||
|
||||
+15
-2
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.62"
|
||||
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"
|
||||
|
||||
@@ -58,6 +58,19 @@ once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
|
||||
# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
|
||||
# kernel-mount NFS path with an in-process implementation that works
|
||||
# in any container — no kernel modules, no CAP_SYS_ADMIN, no
|
||||
# subprocess. Ships alongside the userspace SMB consumer; operators
|
||||
# pick whichever protocol their NAS prefers.
|
||||
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" }
|
||||
|
||||
@@ -44,6 +44,14 @@ struct Inner {
|
||||
/// MIME of the active logo, mirroring `logo_filename`. Cached here
|
||||
/// so the HTTP layer can set Content-Type without re-sniffing.
|
||||
logo_mime: Option<String>,
|
||||
/// Monotonic counter bumped on every set/clear. Surfaces as a
|
||||
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
|
||||
/// fetches the new bytes the moment the operator swaps the logo —
|
||||
/// the app version alone can't do this since it doesn't change on
|
||||
/// upload. Persisted so the token stays stable across restarts and
|
||||
/// keeps climbing across multiple swaps.
|
||||
#[serde(default)]
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk override registry. Cheap to clone; locks are
|
||||
@@ -150,6 +158,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
g.logo_filename = Some(filename.clone());
|
||||
g.logo_mime = Some(mime.to_string());
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
@@ -166,6 +175,7 @@ impl BrandingStore {
|
||||
let mut g = self.inner.write();
|
||||
let removed = g.logo_filename.take();
|
||||
g.logo_mime = None;
|
||||
g.rev = g.rev.wrapping_add(1);
|
||||
removed
|
||||
};
|
||||
if let Some(name) = removed {
|
||||
@@ -185,6 +195,14 @@ impl BrandingStore {
|
||||
self.inner.read().logo_filename.is_some()
|
||||
}
|
||||
|
||||
/// Cache-bust token for the logo asset URL. Changes on every
|
||||
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
|
||||
/// whenever the operator swaps the brand mark. Stable otherwise.
|
||||
#[must_use]
|
||||
pub fn logo_rev(&self) -> u64 {
|
||||
self.inner.read().rev
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
@@ -292,6 +310,23 @@ mod tests {
|
||||
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logo_rev_bumps_on_each_set_and_clear() {
|
||||
let dir = tempdir().unwrap();
|
||||
let b = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b.logo_rev(), 0);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
|
||||
assert_eq!(b.logo_rev(), 1);
|
||||
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
|
||||
assert_eq!(b.logo_rev(), 2);
|
||||
b.clear_logo().unwrap();
|
||||
assert_eq!(b.logo_rev(), 3);
|
||||
// Survives a restart.
|
||||
drop(b);
|
||||
let b2 = BrandingStore::load_or_default(dir.path());
|
||||
assert_eq!(b2.logo_rev(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ext_strips_separators_and_path_chars() {
|
||||
assert_eq!(sanitize_ext("svg"), "svg");
|
||||
@@ -319,6 +354,7 @@ mod tests {
|
||||
let inner = Inner {
|
||||
logo_filename: Some("logo.png".into()),
|
||||
logo_mime: Some("image/png".into()),
|
||||
rev: 0,
|
||||
};
|
||||
std::fs::write(
|
||||
brand_dir.join("branding.json"),
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+566
-83
@@ -31,11 +31,11 @@ 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, NfsAddRequest};
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
@@ -132,10 +132,21 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
|
||||
.route("/api/queue/assign", post(api_queue_assign))
|
||||
.route("/api/queue/:entry_id", delete(api_queue_release))
|
||||
// Phase 4: NFS share manager.
|
||||
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
||||
.route("/api/nfs/:id", delete(api_nfs_remove))
|
||||
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
||||
// v0.4.65: SMB share manager (userspace via smbclient). The
|
||||
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
|
||||
// didn't work on hosts whose kernel lacked the nfs client
|
||||
// modules (Unraid), and no container-side configuration could
|
||||
// load a host kernel module. `smbclient` speaks SMB over a
|
||||
// plain TCP socket in userspace, works in every container.
|
||||
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
|
||||
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
|
||||
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
|
||||
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
||||
// Ships alongside SMB. Routes are parallel so the UI can
|
||||
// reuse the same form/error/hint rendering for both.
|
||||
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
|
||||
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
|
||||
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
|
||||
// Phase 4: Network info (read-only) + DNS edit.
|
||||
.route("/api/network", get(api_network).put(api_network_put))
|
||||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||||
@@ -148,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).
|
||||
@@ -197,7 +218,15 @@ async fn index(State(state): State<AppState>) -> Response {
|
||||
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
|
||||
// binary version — every release ships a new value, every release
|
||||
// forces a fresh URL on each asset.
|
||||
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
|
||||
// logo_rev cache-busts the brand mark / favicon independently of
|
||||
// the release version, so an operator who swaps the custom logo
|
||||
// sees it update on the next reload without waiting for an upgrade.
|
||||
let html = openpxe_webui::index_html(
|
||||
&state.public_base_url,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
state.branding.logo_rev(),
|
||||
state.branding.has_logo(),
|
||||
);
|
||||
(
|
||||
[
|
||||
(
|
||||
@@ -300,66 +329,62 @@ 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();
|
||||
};
|
||||
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,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
)
|
||||
.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,
|
||||
};
|
||||
// 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))
|
||||
|
||||
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, "failed to compose PXE logo PNG"
|
||||
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,
|
||||
format!("failed to compose PXE logo: {e}"),
|
||||
"failed to compose PXE background",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pxe-logo task failed: {e}"),
|
||||
format!("pxe-background task failed: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -367,7 +392,12 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> 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,
|
||||
)
|
||||
@@ -593,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));
|
||||
}
|
||||
}
|
||||
@@ -638,12 +679,111 @@ async fn iso_raw(
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
|
||||
// v0.4.65: SMB-sourced ISOs have no on-disk path — they're
|
||||
// streamed live from the remote share via `smbclient`. We look
|
||||
// up the meta first to decide whether to take the path-based
|
||||
// local route or the subprocess-based SMB route.
|
||||
let Some(meta) = state.iso_store.get(id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
match &meta.source {
|
||||
IsoSource::Local => {
|
||||
let Some(path) = state.iso_store.iso_path_for(id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
Err(e) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
IsoSource::Smb {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
// Range requests aren't supported for SMB sources in
|
||||
// v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
|
||||
// chain loading and ISO sanboot do whole-file sequential
|
||||
// reads, so this works in practice. A 416 here lets the
|
||||
// client fall back to a full GET if it tried a range.
|
||||
if headers.get(header::RANGE).is_some() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
match state.smb_shares.stream_iso(share_id, relative_path).await {
|
||||
Ok(stream) => {
|
||||
let reader = stream.stdout;
|
||||
let body_stream = tokio_util::io::ReaderStream::new(reader);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_LENGTH, meta.size_bytes)
|
||||
// Tell intermediaries we don't support
|
||||
// ranges on this resource; saves them from
|
||||
// even trying.
|
||||
.header(header::ACCEPT_RANGES, "none")
|
||||
.body(Body::from_stream(body_stream))
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
IsoSource::Nfs {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
// v0.4.67: NFS sources support Range requests because
|
||||
// NFSv3 READ3 takes an explicit offset. We resolve the
|
||||
// requested byte range here and pass start/len down to
|
||||
// the streamer which seeks into the file via READ3.
|
||||
let total = meta.size_bytes;
|
||||
let range = match parse_range(headers.get(header::RANGE), total) {
|
||||
Some(triple) => triple,
|
||||
None if headers.get(header::RANGE).is_some() => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
// No Range header — serve the whole file.
|
||||
None => (0, total.saturating_sub(1), false),
|
||||
};
|
||||
let (start, end, partial) = range;
|
||||
let len = if total == 0 { 0 } else { end - start + 1 };
|
||||
let max_len = if total == 0 { None } else { Some(len) };
|
||||
match state
|
||||
.nfs_shares
|
||||
.stream_iso(share_id, relative_path, start, max_len)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
let body = Body::from_stream(stream);
|
||||
let status = if partial {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, len);
|
||||
if partial {
|
||||
builder = builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{total}"),
|
||||
);
|
||||
}
|
||||
builder.body(body).unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +791,10 @@ async fn iso_file(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((id, path)): AxumPath<(String, String)>,
|
||||
) -> Response {
|
||||
// In-ISO file extraction is only supported for local ISOs — it
|
||||
// needs random-access reads into the ISO9660 directory tree, which
|
||||
// smbclient's whole-file streaming can't do efficiently. SMB-
|
||||
// sourced ISOs use the raw streaming endpoint above instead.
|
||||
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
@@ -1026,17 +1170,30 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Abort a chunked upload session and remove the .partial file."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "SMB shares",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/smb-shares",
|
||||
"summary": "List configured SMB shares with connection state and iso counts."},
|
||||
{"method": "POST", "path": "/api/smb-shares",
|
||||
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
|
||||
{"method": "DELETE", "path": "/api/smb-shares/:id",
|
||||
"summary": "Forget a share and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/smb-shares/:id/scan",
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "NFS shares",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/nfs",
|
||||
"summary": "List configured NFS shares with mount state and iso counts."},
|
||||
{"method": "POST", "path": "/api/nfs",
|
||||
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
|
||||
{"method": "DELETE", "path": "/api/nfs/:id",
|
||||
"summary": "Unmount a share and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/nfs/:id/scan",
|
||||
"summary": "Re-walk a mounted share for ISOs."},
|
||||
{"method": "GET", "path": "/api/nfs-shares",
|
||||
"summary": "List configured NFSv3 shares with connection state and iso counts."},
|
||||
{"method": "POST", "path": "/api/nfs-shares",
|
||||
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
|
||||
{"method": "DELETE", "path": "/api/nfs-shares/:id",
|
||||
"summary": "Forget a share and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1111,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": [
|
||||
@@ -1453,8 +1625,13 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
|
||||
|
||||
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
||||
let nfs = state.nfs.list();
|
||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
|
||||
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
|
||||
// sums both so operators see a single "reachable shares" number.
|
||||
let smb_shares = state.smb_shares.list();
|
||||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||||
let nfs_shares = state.nfs_shares.list();
|
||||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
||||
let isos = state.iso_store.list();
|
||||
let clients = state.clients.list();
|
||||
let queue_entries = state.queue.list();
|
||||
@@ -1473,7 +1650,9 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state.metrics.set_nfs_active(nfs_active as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active((smb_reachable + nfs_reachable) as u64);
|
||||
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
||||
@@ -1488,8 +1667,13 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
||||
"settings": state.settings.snapshot(),
|
||||
"smb": smb,
|
||||
"nfs_count": nfs.len(),
|
||||
"nfs_active": nfs_active,
|
||||
"smb_share_count": smb_shares.len(),
|
||||
"smb_share_reachable": smb_reachable,
|
||||
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
|
||||
// with the SMB counts above ("N shares reachable") so the
|
||||
// top-line metric works regardless of protocol mix.
|
||||
"nfs_share_count": nfs_shares.len(),
|
||||
"nfs_share_reachable": nfs_reachable,
|
||||
"host_bindings": state.hosts.len(),
|
||||
"custom_logo": state.branding.has_logo(),
|
||||
"uptime_secs": uptime_secs,
|
||||
@@ -1704,32 +1888,83 @@ async fn api_queue_release(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NFS share API ─────────────────────────────────────────────────────────
|
||||
// ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
|
||||
//
|
||||
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
|
||||
// — a {shares: [...]} list, a POST that returns either the share or a
|
||||
// structured {error, stderr, hint} body — so the UI can render both the
|
||||
// same way.
|
||||
|
||||
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "mounts": state.nfs.list() }))
|
||||
async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "shares": state.smb_shares.list() }))
|
||||
}
|
||||
|
||||
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
||||
match state.nfs.add(req).await {
|
||||
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
||||
// Anything from the manager surfaces as a user-fixable validation
|
||||
// error — bad host, kernel without NFS support, missing
|
||||
// `mount.nfs`, dead server. We pass the message through verbatim
|
||||
// so the UI can show it to the operator.
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
async fn api_smb_shares_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SmbAddRequest>,
|
||||
) -> Response {
|
||||
match state.smb_shares.add(req).await {
|
||||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
||||
match state.nfs.remove(&id).await {
|
||||
async fn api_smb_shares_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.smb_shares.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
||||
match state.nfs.rescan(&id).await {
|
||||
async fn api_smb_shares_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.smb_shares.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
|
||||
//
|
||||
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
|
||||
// (`nfs3_client`) gives us in-process listing and streaming, no
|
||||
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
|
||||
// requests — NFSv3 READ3 takes an explicit offset.
|
||||
|
||||
async fn api_nfs_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "shares": state.nfs_shares.list() }))
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NfsAddRequest>,
|
||||
) -> Response {
|
||||
match state.nfs_shares.add(req).await {
|
||||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs_shares.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_shares_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs_shares.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
@@ -1823,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> {
|
||||
@@ -1849,9 +2298,21 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
|
||||
// v0.4.67: gauge tracks reachable external-storage shares
|
||||
// across both protocols (SMB userspace + NFSv3 in-process).
|
||||
let smb_ok = state
|
||||
.smb_shares
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
let nfs_ok = state
|
||||
.nfs_shares
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64);
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
||||
@@ -1870,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,9 +2,9 @@ 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, NfsManager, SmbManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -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,
|
||||
@@ -44,11 +48,20 @@ pub struct AppState {
|
||||
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
|
||||
/// Windows support is not wired in. Settings toggle drives start/stop.
|
||||
pub smb: Option<Arc<SmbManager>>,
|
||||
/// NFS share manager. Always present (mounting is opt-in by the
|
||||
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
|
||||
/// available in the runtime image. Surfaces errors per-mount rather
|
||||
/// than failing the global state.
|
||||
pub nfs: NfsManager,
|
||||
/// v0.4.65: SMB share manager — userspace consumer of remote SMB
|
||||
/// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
|
||||
/// NFS path that v0.4.64 shipped; that path didn't work on hosts
|
||||
/// (Unraid, etc.) whose kernel ships without the nfs/cifs client
|
||||
/// modules, and no container-side configuration could fix it.
|
||||
/// `smbclient` does the SMB protocol over a plain TCP socket in
|
||||
/// userspace — works in any container, no special caps required.
|
||||
pub smb_shares: SmbShareManager,
|
||||
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
|
||||
/// via the `nfs3_client` crate. Ships alongside the SMB manager
|
||||
/// so operators pick whichever protocol their NAS prefers.
|
||||
/// In-process (no subprocess); supports HTTP Range requests on
|
||||
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
|
||||
pub nfs_shares: NfsShareManager,
|
||||
/// Browser chunked upload state. Multipart uploads still go straight
|
||||
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
|
||||
/// can show deterministic progress and leave visible partial files.
|
||||
|
||||
+186
-56
@@ -88,8 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
"isos" | "images" => Ok(isos_text(state)),
|
||||
"clients" => Ok(clients_text(state)),
|
||||
"queue" => queue_command(state, tail).await,
|
||||
"nfs" => nfs_command(state, tail).await,
|
||||
// `smb` controls the outbound Samba server for Windows
|
||||
// install media. `share` lists/manages remote SMB shares
|
||||
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
|
||||
// command for remote NFSv3 shares (v0.4.67, in-process via
|
||||
// nfs3_client — not the v0.4.64 kernel-mount path).
|
||||
"share" | "smb-share" => smb_share_command(state, tail).await,
|
||||
"smb" => smb_command(state, tail).await,
|
||||
"nfs" => nfs_share_command(state, tail).await,
|
||||
"log" => log_command(state, tail),
|
||||
"whoami" => Ok("operator".to_string()),
|
||||
"echo" => Ok(tail.join(" ")),
|
||||
@@ -107,18 +113,22 @@ fn status_text(s: &AppState) -> String {
|
||||
let clients = s.clients.list();
|
||||
let queue_entries = s.queue.list();
|
||||
let smb = s.smb.as_ref().map(|m| m.snapshot());
|
||||
let nfs = s.nfs.list();
|
||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||
let smb_shares = s.smb_shares.list();
|
||||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||||
// v0.4.67: NFSv3 sources too.
|
||||
let nfs_shares = s.nfs_shares.list();
|
||||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
||||
format!(
|
||||
"OpenPXE {ver}\n\
|
||||
base url: {base}\n\
|
||||
interface: {nic}\n\
|
||||
uptime: {up}\n\
|
||||
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
|
||||
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\
|
||||
clients: {n_clients}\n\
|
||||
queue: {n_entries}\n\
|
||||
smb: {smb}\n\
|
||||
nfs mounts: {n_total} configured ({n_active} active)\n",
|
||||
smb server: {smb}\n\
|
||||
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
|
||||
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n",
|
||||
ver = env!("CARGO_PKG_VERSION"),
|
||||
base = s.public_base_url,
|
||||
nic = if s.nic_name.is_empty() {
|
||||
@@ -132,15 +142,21 @@ fn status_text(s: &AppState) -> String {
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
|
||||
.count(),
|
||||
n_smb = isos
|
||||
.iter()
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
|
||||
.count(),
|
||||
n_nfs = isos
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
|
||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
|
||||
.count(),
|
||||
n_clients = clients.len(),
|
||||
n_entries = queue_entries.len(),
|
||||
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
||||
n_total = nfs.len(),
|
||||
n_active = nfs_active,
|
||||
n_smb_total = smb_shares.len(),
|
||||
n_smb_active = smb_reachable,
|
||||
n_nfs_total = nfs_shares.len(),
|
||||
n_nfs_active = nfs_reachable,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +174,9 @@ fn isos_text(s: &AppState) -> String {
|
||||
for i in isos {
|
||||
let src = match i.source {
|
||||
openpxe_iso_store::IsoSource::Local => "local".to_string(),
|
||||
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
|
||||
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
|
||||
// v0.4.67: NFSv3 via in-process nfs3_client.
|
||||
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -264,31 +282,136 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs ────────────────────────────────────────────────────────────────
|
||||
// ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
|
||||
|
||||
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let mounts = s.nfs.list();
|
||||
if mounts.is_empty() {
|
||||
return Ok("(no NFS mounts configured)".into());
|
||||
let shares = s.smb_shares.list();
|
||||
if shares.is_empty() {
|
||||
return Ok("(no SMB shares configured)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<6} {:<7} {:<6} TARGET",
|
||||
"ID", "VER", "STATUS", "ISOS"
|
||||
"{:<24} {:<7} {:<6} {:<6} TARGET",
|
||||
"ID", "STATUS", "AUTH", "ISOS"
|
||||
);
|
||||
for m in mounts {
|
||||
let status = if m.mounted { "ok" } else { "down" };
|
||||
for m in shares {
|
||||
let status = if m.reachable { "ok" } else { "down" };
|
||||
let auth = if m.guest { "guest" } else { "user" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<6} {:<7} {:<6} {}:{}",
|
||||
"{:<24} {:<7} {:<6} {:<6} //{}/{}",
|
||||
truncate(&m.id, 24),
|
||||
status,
|
||||
auth,
|
||||
m.iso_count,
|
||||
m.server,
|
||||
m.share,
|
||||
);
|
||||
if let Some(e) = m.last_error {
|
||||
let _ = writeln!(out, " error: {e}");
|
||||
}
|
||||
if let Some(h) = m.last_hint {
|
||||
let _ = writeln!(out, " hint: {h}");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("add") => {
|
||||
// share add //server/share [guest|user:password]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| {
|
||||
"usage: share add //server/share [guest|user:password]".to_string()
|
||||
})?;
|
||||
// Accept either `//server/share` (UNC-style) or
|
||||
// `server:share` (shorter to type).
|
||||
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
|
||||
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
|
||||
(s, p)
|
||||
} else if let Some((s, p)) = stripped.split_once(':') {
|
||||
(s, p)
|
||||
} else {
|
||||
return Err("target must be '//server/share' or 'server:share'".into());
|
||||
};
|
||||
|
||||
// Auth spec: "guest" or "user:password". Default: guest.
|
||||
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
|
||||
let (guest, username, password) = if auth == "guest" {
|
||||
(true, None, None)
|
||||
} else if let Some((u, p)) = auth.split_once(':') {
|
||||
(false, Some(u.to_string()), Some(p.to_string()))
|
||||
} else {
|
||||
return Err("auth must be 'guest' or 'user:password'".into());
|
||||
};
|
||||
|
||||
let req = openpxe_iso_store::SmbAddRequest {
|
||||
server: server.to_string(),
|
||||
share: share.to_string(),
|
||||
username,
|
||||
password,
|
||||
guest,
|
||||
port: None,
|
||||
};
|
||||
match s.smb_shares.add(req).await {
|
||||
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
|
||||
Err(e) => {
|
||||
let mut out = format!("add failed: {}", e.error);
|
||||
if let Some(h) = e.hint {
|
||||
out.push_str("\nhint: ");
|
||||
out.push_str(&h);
|
||||
}
|
||||
Err(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("remove") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: share remove <id>".to_string())?;
|
||||
match s.smb_shares.remove(id).await {
|
||||
Ok(()) => Ok(format!("removed {id}")),
|
||||
Err(e) => Err(format!("remove failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("scan") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: share scan <id>".to_string())?;
|
||||
match s.smb_shares.rescan(id).await {
|
||||
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
||||
Err(e) => Err(format!("scan failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
|
||||
|
||||
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let shares = s.nfs_shares.list();
|
||||
if shares.is_empty() {
|
||||
return Ok("(no NFS shares configured)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<7} {:<6} TARGET",
|
||||
"ID", "STATUS", "ISOS"
|
||||
);
|
||||
for m in shares {
|
||||
let status = if m.reachable { "ok" } else { "down" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<7} {:<6} {}:{}",
|
||||
truncate(&m.id, 24),
|
||||
match m.version {
|
||||
openpxe_iso_store::NfsVersion::V3 => "v3",
|
||||
openpxe_iso_store::NfsVersion::V41 => "v4.1",
|
||||
},
|
||||
status,
|
||||
m.iso_count,
|
||||
m.server,
|
||||
@@ -297,56 +420,58 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
if let Some(e) = m.last_error {
|
||||
let _ = writeln!(out, " error: {e}");
|
||||
}
|
||||
if let Some(h) = m.last_hint {
|
||||
let _ = writeln!(out, " hint: {h}");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("mount") => {
|
||||
// nfs mount <server>:<export> [v3|v41] [ro|rw]
|
||||
Some("add") => {
|
||||
// nfs add <server>:<export> [port]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
|
||||
.ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
|
||||
let (server, export) = target
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
|
||||
let version = match args.get(2).map(String::as_str) {
|
||||
Some("v3") => openpxe_iso_store::NfsVersion::V3,
|
||||
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
|
||||
Some(other) => {
|
||||
return Err(format!("unknown nfs version: {other} (expect v3 or v41)"))
|
||||
}
|
||||
};
|
||||
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
|
||||
let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
|
||||
let req = openpxe_iso_store::NfsAddRequest {
|
||||
server: server.to_string(),
|
||||
export: export.to_string(),
|
||||
version,
|
||||
read_only,
|
||||
port,
|
||||
};
|
||||
match s.nfs.add(req).await {
|
||||
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
|
||||
Err(e) => Err(format!("mount failed: {e}")),
|
||||
match s.nfs_shares.add(req).await {
|
||||
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
|
||||
Err(e) => {
|
||||
let mut out = format!("add failed: {}", e.error);
|
||||
if let Some(h) = e.hint {
|
||||
out.push_str("\nhint: ");
|
||||
out.push_str(&h);
|
||||
}
|
||||
Err(out)
|
||||
}
|
||||
}
|
||||
Some("unmount") => {
|
||||
}
|
||||
Some("remove") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
|
||||
match s.nfs.remove(id).await {
|
||||
Ok(()) => Ok(format!("unmounted {id}")),
|
||||
Err(e) => Err(format!("unmount failed: {e}")),
|
||||
.ok_or_else(|| "usage: nfs remove <id>".to_string())?;
|
||||
match s.nfs_shares.remove(id).await {
|
||||
Ok(()) => Ok(format!("removed {id}")),
|
||||
Err(e) => Err(format!("remove failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("scan") => {
|
||||
let id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
|
||||
match s.nfs.rescan(id).await {
|
||||
match s.nfs_shares.rescan(id).await {
|
||||
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
||||
Err(e) => Err(format!("scan failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
|
||||
"unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -487,13 +612,18 @@ OpenPXE terminal — available commands:
|
||||
queue assign-all <target> assign every waiting client
|
||||
queue release <entry_id> release one queued client
|
||||
|
||||
nfs list list NFS mounts
|
||||
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
|
||||
nfs unmount <id> unmount and forget a share
|
||||
nfs scan <id> re-scan a share for new ISOs
|
||||
share list list configured SMB shares
|
||||
share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
|
||||
share remove <id> forget an SMB share
|
||||
share scan <id> re-list a share for new ISOs
|
||||
|
||||
smb status SMB (Samba) state
|
||||
smb start | stop | reload control smbd
|
||||
nfs list list configured NFSv3 shares
|
||||
nfs add <srv>:<export> [port] add an NFSv3 share
|
||||
nfs remove <id> forget an NFS share
|
||||
nfs scan <id> re-list an NFS share for new ISOs
|
||||
|
||||
smb status outbound Samba state (Windows install media)
|
||||
smb start | stop | reload control the outbound smbd
|
||||
|
||||
log clear drop the in-memory log ring buffer
|
||||
log tail [n] show the last n buffered lines (default 20)
|
||||
@@ -508,10 +638,10 @@ mod tests {
|
||||
#[test]
|
||||
fn shell_split_basic() {
|
||||
assert_eq!(shell_split(""), Vec::<String>::new());
|
||||
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
|
||||
assert_eq!(shell_split("share list"), vec!["share", "list"]);
|
||||
assert_eq!(
|
||||
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
|
||||
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
|
||||
shell_split("share add //nas/isos guest"),
|
||||
vec!["share", "add", "//nas/isos", "guest"]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
||||
use openpxe_http_api::{build_router, AppState};
|
||||
use openpxe_iso_store::{IsoStore, NfsManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager};
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -94,14 +94,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let clients = ClientRegistry::new();
|
||||
let queue = DeploymentQueue::new();
|
||||
let settings = SettingsStore::load_or_default(dir.path());
|
||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
|
||||
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
|
||||
let log_bus = LogBus::new(64);
|
||||
let hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
|
||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||
let 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,9 +116,11 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
admin,
|
||||
sessions,
|
||||
sso,
|
||||
notify,
|
||||
metrics,
|
||||
smb: None,
|
||||
nfs,
|
||||
smb_shares,
|
||||
nfs_shares,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
@@ -464,35 +467,168 @@ async fn no_external_urls_in_generated_ipxe() {
|
||||
|
||||
// ── Phase 4 integration tests ────────────────────────────────────────────
|
||||
|
||||
// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient.
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_add_with_bad_export_is_rejected() {
|
||||
// Validation must happen before we shell out to /bin/mount —
|
||||
// otherwise the operator sees opaque kernel errors instead of a
|
||||
// clear "your export must start with /" hint.
|
||||
async fn smb_share_add_with_missing_server_is_rejected() {
|
||||
// Validation must run before we shell out to smbclient — otherwise
|
||||
// operators see opaque NT_STATUS codes for what's really a typo.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs",
|
||||
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
|
||||
"/api/smb-shares",
|
||||
r#"{"server":"","share":"isos","guest":true}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.contains("export"),
|
||||
msg.to_lowercase().contains("server"),
|
||||
"expected validation hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_list_starts_empty() {
|
||||
async fn smb_share_add_requires_username_when_not_guest() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/nfs").await;
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/smb-shares",
|
||||
r#"{"server":"10.0.0.5","share":"isos","guest":false}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("username"),
|
||||
"expected username hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smb_share_add_rejects_paths_in_share_name() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/smb-shares",
|
||||
r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("share name"),
|
||||
"expected share name hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smb_shares_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/smb-shares").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// 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;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/nfs-shares").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_add_rejects_missing_server() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs-shares",
|
||||
r#"{"server":"","export":"/srv/isos"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("server"),
|
||||
"expected server hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_add_rejects_export_without_leading_slash() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs-shares",
|
||||
r#"{"server":"10.0.0.5","export":"srv/isos"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("export"),
|
||||
"expected export-path hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1746,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
|
||||
@@ -1771,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
|
||||
@@ -1785,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]
|
||||
|
||||
@@ -35,5 +35,13 @@ libc = "0.2"
|
||||
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
|
||||
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
|
||||
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
|
||||
nfs3_client = { workspace = true }
|
||||
nfs3_types = { workspace = true }
|
||||
# Needed for the Stream trait that wraps the mpsc receiver feeding
|
||||
# NFS read-loop bytes into axum's Body::from_stream.
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
|
||||
@@ -18,16 +18,27 @@
|
||||
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs;
|
||||
pub mod nfs_share;
|
||||
pub mod pxe_logo;
|
||||
pub mod smb;
|
||||
pub mod smb_share;
|
||||
pub mod store;
|
||||
pub mod windows;
|
||||
|
||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
|
||||
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
|
||||
// `smbclient` CLI replaced it — works in any container (no
|
||||
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
|
||||
// every other PXE/imaging tool that supports network storage handles
|
||||
// it.
|
||||
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
||||
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
|
||||
// v0.4.67: NFS is back — this time as an in-process userspace NFSv3
|
||||
// client (the `nfs3_client` crate) rather than a kernel mount. Same
|
||||
// "works in any container" property as SMB, plus support for HTTP
|
||||
// Range requests because NFSv3 READ3 takes an explicit offset.
|
||||
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
|
||||
pub use store::{
|
||||
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
|
||||
UploadHandle,
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
//! NFS share manager.
|
||||
//!
|
||||
//! Lets an operator mount a remote NFS export as an ISO source instead of
|
||||
//! uploading every ISO into the container's PVC. Supports NFSv3 and
|
||||
//! NFSv4.1 — the two versions the user explicitly asked for.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! 1. Operator submits a mount spec via the Storage tab:
|
||||
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
|
||||
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
|
||||
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
|
||||
//! 3. On success we walk the mount point looking for `*.iso` files and
|
||||
//! register each one with the `IsoStore` as an external source — same
|
||||
//! introspection pipeline as a web upload, but no sha256 (the bytes
|
||||
//! live on a remote machine; hashing them would suck them through the
|
||||
//! network on every restart).
|
||||
//! 4. On failure we record `last_error` on the spec and persist anyway
|
||||
//! so the UI can show a row in red rather than silently dropping it.
|
||||
//!
|
||||
//! ## Operational notes
|
||||
//!
|
||||
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
|
||||
//! `nfs-common` package. The default image ships these (see Dockerfile).
|
||||
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
|
||||
//! doesn't — operators have to opt in by switching to a more privileged
|
||||
//! SCC or running NFS mounts as a CSI driver outside the pod.
|
||||
//! - Mount commands are issued sequentially under a single mutex to avoid
|
||||
//! `mount` racing on the same target dir.
|
||||
//!
|
||||
//! ## Persistence
|
||||
//!
|
||||
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
|
||||
//! re-mounted on startup. Mounts that fail to come back online keep their
|
||||
//! spec and their `last_error` so the operator sees what happened.
|
||||
|
||||
use crate::introspect::{introspect, IntrospectionReport};
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use openpxe_core::{Error, Result};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Wire-protocol versions we support. Keep this enum closed — silently
|
||||
/// accepting "auto" or letting the kernel negotiate would mean operators
|
||||
/// could never confirm which version is in use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NfsVersion {
|
||||
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
|
||||
/// older NAS appliances.
|
||||
V3,
|
||||
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
|
||||
V41,
|
||||
}
|
||||
|
||||
impl NfsVersion {
|
||||
fn vers_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::V3 => "vers=3",
|
||||
Self::V41 => "vers=4.1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One configured mount. The id is generated from server+export so the
|
||||
/// operator can re-add the same export idempotently.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NfsMount {
|
||||
pub id: String,
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
pub version: NfsVersion,
|
||||
/// Read-only by default — most ISO libraries are. Operators that need
|
||||
/// write can flip this off but OpenPXE itself never writes.
|
||||
pub read_only: bool,
|
||||
/// Local mount point under `<work_dir>/nfs/`.
|
||||
pub local_path: PathBuf,
|
||||
/// Whether the mount is currently active.
|
||||
pub mounted: bool,
|
||||
/// Last error encountered on a `mount` or `umount` attempt; cleared on
|
||||
/// success.
|
||||
pub last_error: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_attempt: Option<OffsetDateTime>,
|
||||
/// Number of `.iso` files found on the share (re-counted on each scan).
|
||||
pub iso_count: u32,
|
||||
}
|
||||
|
||||
/// Spec submitted by the UI. Server and export are normalized before use.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NfsAddRequest {
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
#[serde(default = "default_version")]
|
||||
pub version: NfsVersion,
|
||||
#[serde(default = "default_ro")]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
fn default_version() -> NfsVersion {
|
||||
NfsVersion::V41
|
||||
}
|
||||
fn default_ro() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
mounts: HashMap<String, NfsMount>,
|
||||
}
|
||||
|
||||
/// Manages NFS mounts and surfaces them as ISO sources.
|
||||
///
|
||||
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NfsManager {
|
||||
work_root: Arc<PathBuf>,
|
||||
state_path: Arc<PathBuf>,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
iso_store: IsoStore,
|
||||
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
|
||||
/// avoids racing on the same target directory.
|
||||
mount_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl NfsManager {
|
||||
/// Construct a manager rooted at `work_dir`. Mount points live under
|
||||
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
|
||||
#[must_use]
|
||||
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
||||
let work_root = work_dir.join("nfs");
|
||||
let state_path = work_dir.join("nfs.json");
|
||||
Self {
|
||||
work_root: Arc::new(work_root),
|
||||
state_path: Arc::new(state_path),
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this manager mounts shares. Used by `IsoStore` to resolve
|
||||
/// NFS-backed `IsoMeta`s to their on-disk path.
|
||||
#[must_use]
|
||||
pub fn mount_root(&self) -> PathBuf {
|
||||
self.work_root.as_ref().clone()
|
||||
}
|
||||
|
||||
/// Load persisted state and re-attempt every mount. Errors are logged
|
||||
/// per-mount but never fail the call — startup must not block on a
|
||||
/// remote NFS server being slow.
|
||||
pub async fn load_and_remount(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
|
||||
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
||||
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
for mut m in mounts {
|
||||
// Always start from "not mounted" — the kernel state was lost
|
||||
// when the process died. We'll try to remount each one.
|
||||
m.mounted = false;
|
||||
m.last_error = None;
|
||||
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
|
||||
if let Err(e) = self.try_mount(&m.id).await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
id = %m.id, error = %e,
|
||||
"could not remount NFS share on startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
|
||||
/// reflecting reality) or an error if the spec was invalid.
|
||||
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
|
||||
let server = req.server.trim().to_string();
|
||||
let export = req.export.trim().to_string();
|
||||
if server.is_empty() {
|
||||
return Err(Error::Invalid("server is required".into()));
|
||||
}
|
||||
if !export.starts_with('/') {
|
||||
return Err(Error::Invalid("export path must start with '/'".into()));
|
||||
}
|
||||
|
||||
let id = mount_id(&server, &export);
|
||||
let local_path = self.work_root.join(&id);
|
||||
tokio::fs::create_dir_all(&local_path).await?;
|
||||
|
||||
let mount = NfsMount {
|
||||
id: id.clone(),
|
||||
server,
|
||||
export,
|
||||
version: req.version,
|
||||
read_only: req.read_only,
|
||||
local_path,
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
self.inner.lock().mounts.insert(id.clone(), mount);
|
||||
self.persist_locked();
|
||||
self.try_mount(&id).await?;
|
||||
Ok(self.get(&id).expect("mount just inserted"))
|
||||
}
|
||||
|
||||
/// Unmount and forget a share. Removes any ISOs it contributed from
|
||||
/// the IsoStore and deletes the local mount point. Idempotent.
|
||||
pub async fn remove(&self, id: &str) -> Result<()> {
|
||||
// Best-effort umount; even if it fails (e.g. server unreachable)
|
||||
// we still want to drop the in-memory record.
|
||||
let _ = self.umount_one(id).await;
|
||||
let local_path = {
|
||||
let mut g = self.inner.lock();
|
||||
g.mounts.remove(id).map(|m| m.local_path)
|
||||
};
|
||||
self.persist_locked();
|
||||
self.iso_store.drop_external_source(id);
|
||||
if let Some(p) = local_path {
|
||||
// rmdir only — never recurse, the mount could still be live
|
||||
// on some kernel error path and we don't want to nuke a
|
||||
// remote filesystem.
|
||||
let _ = tokio::fs::remove_dir(&p).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
|
||||
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
||||
let mount = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
if !mount.mounted {
|
||||
return Err(Error::Invalid(format!("mount '{id}' is not active")));
|
||||
}
|
||||
let count = self.scan_and_register(&mount).await?;
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Snapshot of every configured mount.
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<NfsMount> {
|
||||
let g = self.inner.lock();
|
||||
let mut v: Vec<_> = g.mounts.values().cloned().collect();
|
||||
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Look up a single mount by id.
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<NfsMount> {
|
||||
self.inner.lock().mounts.get(id).cloned()
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────
|
||||
|
||||
async fn try_mount(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
|
||||
let m = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
// Already mounted? Skip — `mount` would error on a busy target
|
||||
// and confuse the operator's UI status.
|
||||
if is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, true, None, now);
|
||||
// Even though already mounted, we still want a fresh ISO count.
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let opts = mount_options(&m);
|
||||
let target = format!("{}:{}", m.server, m.export);
|
||||
|
||||
let output = Command::new("mount")
|
||||
.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg("-o")
|
||||
.arg(&opts)
|
||||
.arg(&target)
|
||||
.arg(&m.local_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(
|
||||
target: "openpxe::nfs",
|
||||
id = %id, server = %m.server, export = %m.export,
|
||||
version = ?m.version,
|
||||
"NFS mount succeeded"
|
||||
);
|
||||
self.update_status(id, true, None, now);
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
Ok(())
|
||||
}
|
||||
Ok(out) => {
|
||||
let err = format!(
|
||||
"mount exit {}: {}",
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), now);
|
||||
Err(Error::Invalid(err))
|
||||
}
|
||||
Err(e) => {
|
||||
let err = format!("could not exec /bin/mount: {e}");
|
||||
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), now);
|
||||
Err(Error::Invalid(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn umount_one(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
let Some(m) = self.get(id) else { return Ok(()) };
|
||||
if !is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
||||
return Ok(());
|
||||
}
|
||||
// -l = lazy: detach immediately, finish when no process has a
|
||||
// handle. Important if a stale ISO read is still in flight.
|
||||
let out = Command::new("umount")
|
||||
.arg("-l")
|
||||
.arg(&m.local_path)
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
||||
Ok(())
|
||||
}
|
||||
Ok(o) => {
|
||||
let e = format!(
|
||||
"umount exit {}: {}",
|
||||
o.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
Err(e) => {
|
||||
let e = format!("could not exec /bin/umount: {e}");
|
||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the mount point for `*.iso` files, introspect each one, and
|
||||
/// register it with the IsoStore as an NFS-sourced entry. Returns the
|
||||
/// count of ISOs registered.
|
||||
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
|
||||
// Drop any prior entries from this mount before re-registering, so
|
||||
// a removed file disappears from the store.
|
||||
self.iso_store.drop_external_source(&m.id);
|
||||
|
||||
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
|
||||
let mut count = 0u32;
|
||||
while let Some(entry) = walker.next_entry().await? {
|
||||
let p = entry.path();
|
||||
if p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
!= Some("iso")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let filename = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(f) => f.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let size = tokio::fs::metadata(&p).await?.len();
|
||||
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
|
||||
// it to a blocking thread so the runtime stays responsive on
|
||||
// a slow share.
|
||||
let p_owned = p.clone();
|
||||
let report: IntrospectionReport =
|
||||
tokio::task::spawn_blocking(move || introspect(&p_owned))
|
||||
.await
|
||||
.map_err(|e| Error::Other(e.into()))?;
|
||||
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
|
||||
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
mount_id: m.id.clone(),
|
||||
relative_path: filename.clone(),
|
||||
};
|
||||
self.iso_store
|
||||
.register_external(id, filename, size, report, boot_entries, source);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.mounted = mounted;
|
||||
m.last_error = err;
|
||||
m.last_attempt = Some(ts);
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
fn update_iso_count(&self, id: &str, count: u32) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
/// Atomically replace the on-disk JSON with the current state.
|
||||
/// Persistence errors are logged, never propagated — settings live in
|
||||
/// memory authoritatively, matching the SettingsStore policy.
|
||||
fn persist_locked(&self) {
|
||||
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
|
||||
let path = self.state_path.as_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = match serde_json::to_vec_pretty(&mounts) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_options(m: &NfsMount) -> String {
|
||||
let mut opts = vec![m.version.vers_arg().to_string()];
|
||||
if m.read_only {
|
||||
opts.push("ro".into());
|
||||
} else {
|
||||
opts.push("rw".into());
|
||||
}
|
||||
// `nolock` for v3 — many storage appliances disable lockd; we don't
|
||||
// need locking for read-only ISO access anyway.
|
||||
if matches!(m.version, NfsVersion::V3) {
|
||||
opts.push("nolock".into());
|
||||
}
|
||||
// Soft mount with a generous timeout — better to surface a hung share
|
||||
// as a user-visible error than to wedge the iPXE client forever on a
|
||||
// dead NFS server.
|
||||
opts.push("soft".into());
|
||||
opts.push("timeo=100".into());
|
||||
opts.push("retrans=3".into());
|
||||
opts.join(",")
|
||||
}
|
||||
|
||||
fn mount_id(server: &str, export: &str) -> String {
|
||||
let raw = format!("{server}{export}");
|
||||
slugify_str(&raw)
|
||||
}
|
||||
|
||||
/// Detect whether `path` is currently a mount point. We don't have
|
||||
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
|
||||
/// if they differ the dir is a mount.
|
||||
async fn is_mountpoint(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m1) = tokio::fs::metadata(path).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m2) = tokio::fs::metadata(parent).await else {
|
||||
return false;
|
||||
};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
m1.dev() != m2.dev()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_arg() {
|
||||
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
|
||||
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v3_includes_nolock() {
|
||||
let m = NfsMount {
|
||||
id: "x".into(),
|
||||
server: "s".into(),
|
||||
export: "/e".into(),
|
||||
version: NfsVersion::V3,
|
||||
read_only: true,
|
||||
local_path: PathBuf::from("/tmp/x"),
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
let opts = mount_options(&m);
|
||||
assert!(opts.contains("vers=3"));
|
||||
assert!(opts.contains("ro"));
|
||||
assert!(opts.contains("nolock"));
|
||||
assert!(opts.contains("soft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v41_no_nolock() {
|
||||
let m = NfsMount {
|
||||
id: "x".into(),
|
||||
server: "s".into(),
|
||||
export: "/e".into(),
|
||||
version: NfsVersion::V41,
|
||||
read_only: false,
|
||||
local_path: PathBuf::from("/tmp/x"),
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
let opts = mount_options(&m);
|
||||
assert!(opts.contains("vers=4.1"));
|
||||
assert!(opts.contains("rw"));
|
||||
assert!(!opts.contains("nolock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_id_is_stable_and_safe() {
|
||||
let a = mount_id("10.0.0.5", "/srv/isos");
|
||||
let b = mount_id("10.0.0.5", "/srv/isos");
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.contains('/'));
|
||||
assert!(!a.contains('.'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
/// 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);
|
||||
|
||||
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();
|
||||
|
||||
// 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));
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,18 +15,31 @@ use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Where the bytes for an ISO actually live.
|
||||
///
|
||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
||||
/// `Nfs` entries point at a file inside a remote share that the
|
||||
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
||||
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
||||
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
|
||||
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
|
||||
/// userspace `smbclient` CLI subprocess. No kernel mount, no local
|
||||
/// cache. Sequential whole-file streaming; HTTP Range requests
|
||||
/// return 416.
|
||||
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
|
||||
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
|
||||
/// any container" property as SMB, plus Range requests work because
|
||||
/// NFSv3 READ3 takes an explicit offset.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum IsoSource {
|
||||
#[default]
|
||||
Local,
|
||||
/// v0.4.65: SMB via userspace `smbclient` works in any container.
|
||||
Smb {
|
||||
share_id: String,
|
||||
/// Filename at the share root. We don't support nested paths
|
||||
/// in v0.4.65; ISOs live at the top of the share.
|
||||
relative_path: String,
|
||||
},
|
||||
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
|
||||
Nfs {
|
||||
mount_id: String,
|
||||
/// Path relative to the mount point — typically just the filename.
|
||||
share_id: String,
|
||||
/// Filename at the export root.
|
||||
relative_path: String,
|
||||
},
|
||||
}
|
||||
@@ -164,10 +177,6 @@ struct Inner {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IsoStore {
|
||||
iso_dir: Arc<PathBuf>,
|
||||
/// Where NFS mounts land on disk. Set at startup via
|
||||
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
||||
/// `IsoSource::Nfs` entry.
|
||||
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
@@ -175,17 +184,10 @@ impl IsoStore {
|
||||
pub fn new(iso_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
iso_dir: Arc::new(iso_dir),
|
||||
nfs_root: Arc::new(RwLock::new(None)),
|
||||
inner: Arc::new(RwLock::new(Inner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the store where NFS mounts live. Without this set,
|
||||
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
||||
pub fn set_nfs_root(&self, root: PathBuf) {
|
||||
*self.nfs_root.write() = Some(root);
|
||||
}
|
||||
|
||||
pub async fn ensure_dirs(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
|
||||
Ok(())
|
||||
@@ -281,33 +283,33 @@ impl IsoStore {
|
||||
self.inner.read().isos.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Resolve an ISO id to its on-disk path, if any. For local entries
|
||||
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
|
||||
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
|
||||
/// is missing or the source isn't resolvable (e.g. NFS share
|
||||
/// unmounted).
|
||||
/// Resolve an ISO id to its on-disk path, if any. For local
|
||||
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
|
||||
/// ISOs there is no on-disk path — the HTTP handler must stream
|
||||
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
|
||||
/// SMB sources or when the file is missing.
|
||||
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
||||
let meta = self.get(id)?;
|
||||
let path = match &meta.source {
|
||||
IsoSource::Local => self.iso_path(id),
|
||||
IsoSource::Nfs {
|
||||
mount_id,
|
||||
relative_path,
|
||||
} => {
|
||||
let root = self.nfs_root.read().clone()?;
|
||||
root.join(mount_id).join(relative_path)
|
||||
}
|
||||
};
|
||||
match &meta.source {
|
||||
IsoSource::Local => {
|
||||
let path = self.iso_path(id);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// SMB and NFS sources have no local path — they're
|
||||
// streamed in-process. Callers must inspect the source
|
||||
// kind first and dispatch to the appropriate share
|
||||
// manager.
|
||||
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
|
||||
/// for NFS-backed ISOs the operator must remove the file from the
|
||||
/// share or unmount the NFS share entirely.
|
||||
/// Delete an ISO and its sidecar metadata. Only acts on local
|
||||
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
|
||||
/// the file from the share or unregister the share entirely.
|
||||
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||
let meta = self.get(id);
|
||||
let is_local = matches!(
|
||||
@@ -324,10 +326,10 @@ impl IsoStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
|
||||
/// `NfsManager` after walking a freshly-mounted share. We do **not**
|
||||
/// persist a `meta.json` on disk for these — the source of truth is
|
||||
/// the share itself, and the NFS manager re-scans on startup.
|
||||
/// Register an externally-sourced ISO (SMB share, etc.). Used by
|
||||
/// `SmbShareManager` after listing a share. We do **not** persist
|
||||
/// a `meta.json` on disk for these — the source of truth is the
|
||||
/// share itself, and the manager re-scans on startup.
|
||||
pub fn register_external(
|
||||
&self,
|
||||
id: String,
|
||||
@@ -352,14 +354,20 @@ impl IsoStore {
|
||||
self.inner.write().isos.insert(id, meta);
|
||||
}
|
||||
|
||||
/// Drop every entry that belongs to `mount_id`. Used by the NFS
|
||||
/// manager when an operator removes a share, or before re-scanning
|
||||
/// to clean out stale entries.
|
||||
pub fn drop_external_source(&self, mount_id: &str) {
|
||||
/// Drop every entry that belongs to `share_id`. Used by the SMB
|
||||
/// and NFS share managers when an operator removes a share, or
|
||||
/// before re-scanning to clean out stale entries. The same id
|
||||
/// space serves both protocols — share ids are slugified from
|
||||
/// `server+share` (SMB) or `server+export` (NFS) and the
|
||||
/// protocol-specific prefix prevents collisions.
|
||||
pub fn drop_external_source(&self, share_id: &str) {
|
||||
let mut g = self.inner.write();
|
||||
g.isos.retain(
|
||||
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
|
||||
);
|
||||
g.isos.retain(|_, m| match &m.source {
|
||||
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
|
||||
sid != share_id
|
||||
}
|
||||
IsoSource::Local => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set or clear an ISO's boot password.
|
||||
|
||||
@@ -9,7 +9,7 @@ use openpxe_core::{
|
||||
};
|
||||
use openpxe_dhcp_proxy::DhcpProxyServer;
|
||||
use openpxe_http_api::{build_router, AppState};
|
||||
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||
use openpxe_tftp::TftpServer;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
@@ -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();
|
||||
|
||||
@@ -119,13 +120,30 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _ = smb.start();
|
||||
}
|
||||
|
||||
// NFS manager. The mount root has to be set on the IsoStore *before*
|
||||
// we replay any persisted mounts, otherwise an in-memory IsoMeta
|
||||
// pointing at an NFS source can't resolve to a path.
|
||||
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
if let Err(e) = nfs.load_and_remount().await {
|
||||
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
|
||||
// v0.4.65: SMB share manager — Samba `smbclient` userspace
|
||||
// consumer. Replaces the kernel-mount NFS path that v0.4.64
|
||||
// shipped; that didn't work on hosts whose kernel lacked the nfs
|
||||
// client modules (Unraid is the dominant case). `smbclient` does
|
||||
// the SMB protocol entirely in userspace over TCP and works in
|
||||
// any container regardless of capabilities or kernel modules.
|
||||
let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
|
||||
if let Err(e) = smb_shares.load_and_rescan().await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::smb",
|
||||
"could not reload SMB shares on startup: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
|
||||
// via the `nfs3_client` crate. Sits alongside the SMB manager;
|
||||
// operators pick whichever protocol their NAS prefers, or use
|
||||
// both. No subprocess, no kernel mount, works in any container.
|
||||
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
|
||||
if let Err(e) = nfs_shares.load_and_rescan().await {
|
||||
tracing::warn!(
|
||||
target: "openpxe::nfs",
|
||||
"could not reload NFS shares on startup: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// Sniff network details for the Network tab. None of these are
|
||||
@@ -150,9 +168,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
admin: admin.clone(),
|
||||
sessions: sessions.clone(),
|
||||
sso: sso.clone(),
|
||||
notify: notify.clone(),
|
||||
metrics: metrics.clone(),
|
||||
smb: Some(smb.clone()),
|
||||
nfs: nfs.clone(),
|
||||
smb_shares: smb_shares.clone(),
|
||||
nfs_shares: nfs_shares.clone(),
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus: log_bus.clone(),
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
|
||||
@@ -34,9 +34,18 @@
|
||||
--topbar-h: 56px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
|
||||
date pickers) to the active OpenPXE theme. Without this, the inline
|
||||
`<meta name="color-scheme" content="dark light">` in index.html forces
|
||||
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
|
||||
checkbox renders as an opaque black square against the light-mode
|
||||
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
|
||||
the meta and tracks `data-theme` correctly. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
/* Light palette — high-contrast neutral, accent unchanged for brand
|
||||
consistency. Designed against Netbox Labs's reference screenshot:
|
||||
near-white surfaces, soft grey dividers, dark text. */
|
||||
@@ -108,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;
|
||||
@@ -304,6 +327,23 @@ label.field textarea {
|
||||
font-size: 14px; line-height: 1.4;
|
||||
box-shadow: none; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
|
||||
disappears, which makes the "Metadata source" pick-list look like a
|
||||
plain (and slightly squished) text input. Paint our own chevron via
|
||||
background-image so the control still reads as a dropdown, and reserve
|
||||
right-padding for it. The data-URI SVG inherits currentColor via the
|
||||
`stroke` attribute so the arrow follows light/dark theme without a
|
||||
second declaration. */
|
||||
label.field select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 11px 7px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
:root[data-theme="light"] label.field select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
|
||||
}
|
||||
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
@@ -313,7 +353,40 @@ label.check {
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius);
|
||||
}
|
||||
label.check input { accent-color: var(--accent); }
|
||||
/* v0.4.63: native checkboxes used to render as opaque black squares in
|
||||
light mode because the page meta declares `color-scheme: dark light`
|
||||
and `accent-color` alone only repaints the *check mark* (not the
|
||||
container). Take full control of the chrome so the box reads cleanly
|
||||
on both palettes and the checked state lights up in our accent. */
|
||||
label.check input[type="checkbox"] {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
width: 16px; height: 16px; flex: none;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
display: inline-grid; place-content: center;
|
||||
cursor: pointer; margin: 0;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
|
||||
label.check input[type="checkbox"]:checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
label.check input[type="checkbox"]:checked::after {
|
||||
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
|
||||
#002923 (the same near-black we use on solid-accent buttons) so the
|
||||
tick stays legible against the teal fill in both themes. */
|
||||
content: '';
|
||||
width: 4px; height: 8px;
|
||||
border: solid #002923;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
label.check input[type="checkbox"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ── Drop zone ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -554,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);
|
||||
}
|
||||
|
||||
+565
-137
@@ -116,6 +116,46 @@
|
||||
return root;
|
||||
}
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the ISO
|
||||
// directory, with a coloured bar. Warns at 80% and goes red at 95% so
|
||||
// the operator sees the runway shrinking before uploads start failing
|
||||
// with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68).
|
||||
function diskSpaceCard(disk) {
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
return el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Categorize an ISO row's "bootable now" status — drives the amber
|
||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||
function bootability(iso, settings) {
|
||||
@@ -142,6 +182,13 @@
|
||||
const isos = await getJSON('/api/isos');
|
||||
const clients = (await getJSON('/api/clients')).clients || [];
|
||||
const entries = (await getJSON('/api/queue')).entries || [];
|
||||
// v0.4.68: surface the same disk-space card the Storage tab shows,
|
||||
// so operators see capacity at a glance from the landing page.
|
||||
// Tolerate the endpoint being unavailable (e.g. statvfs failure)
|
||||
// the same way the Storage tab does.
|
||||
const disk = await getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
}));
|
||||
|
||||
const ipxeOk = (status.ipxe_assets || []).length > 0;
|
||||
const stats = el('div', {class: 'statstrip'}, [
|
||||
@@ -167,7 +214,11 @@
|
||||
el('div', {class: 'trend'},
|
||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
||||
(status.nfs_active || 0) + ' NFS active'),
|
||||
// v0.4.67: count both protocols. Label generically since
|
||||
// operators may be using one, the other, or both.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -218,7 +269,7 @@
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
]) : null;
|
||||
|
||||
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
|
||||
return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
|
||||
},
|
||||
|
||||
network: async () => {
|
||||
@@ -342,13 +393,22 @@
|
||||
},
|
||||
|
||||
storage: async () => {
|
||||
const [isos, settings, nfsRes, disk] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
||||
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
|
||||
// smbclient — works in any container regardless of host kernel
|
||||
// modules or capabilities.
|
||||
// v0.4.67: NFSv3 added back as an in-process Rust client
|
||||
// (nfs3_client crate). Both protocols available side-by-side;
|
||||
// operators pick whichever their NAS prefers.
|
||||
const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'),
|
||||
getJSON('/api/smb-shares'),
|
||||
getJSON('/api/nfs-shares'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
]);
|
||||
const mounts = nfsRes.mounts || [];
|
||||
const shares = smbRes.shares || [];
|
||||
const nfsShares = nfsRes.shares || [];
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
@@ -450,7 +510,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── ISO table (mixed local + NFS) ──
|
||||
// ── ISO table (mixed local + SMB) ──
|
||||
// Each row gets a "Password" cell that toggles a small inline
|
||||
// editor (a checkbox + a password field + Save button) inside the
|
||||
// *next* row of the table. Keeps the markup flat and avoids the
|
||||
@@ -458,7 +518,14 @@
|
||||
const rowsAndEditors = [];
|
||||
isos.forEach(i => {
|
||||
const b = bootability(i, settings);
|
||||
// v0.4.65: SMB userspace consumer (smbclient).
|
||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||
// Both render with the same "remote" badge colour — they
|
||||
// share the same "on a remote share, can't be deleted from
|
||||
// here" semantics in the UI.
|
||||
const isSmb = i.source && i.source.kind === 'smb';
|
||||
const isNfs = i.source && i.source.kind === 'nfs';
|
||||
const isRemote = isSmb || isNfs;
|
||||
const protectedNow = !!i.password_hash;
|
||||
|
||||
// The inline editor row is hidden by default; the Password
|
||||
@@ -578,8 +645,9 @@
|
||||
]),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
||||
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
|
||||
el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
|
||||
isSmb ? ('smb:' + i.source.share_id)
|
||||
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
|
||||
el('td', {},
|
||||
protectedNow
|
||||
? el('span', {class:'tag accent'}, 'protected')
|
||||
@@ -589,8 +657,12 @@
|
||||
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
|
||||
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
|
||||
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
||||
isNfs
|
||||
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
|
||||
isRemote
|
||||
// v0.4.65/v0.4.67: remote-sourced ISOs live on the
|
||||
// share — OpenPXE doesn't own those bytes. Surface a
|
||||
// tag instead of a destructive button.
|
||||
? el('span', {class:'tag', style:'opacity:.6'},
|
||||
isSmb ? 'on SMB' : 'on NFS')
|
||||
: el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||
@@ -610,96 +682,166 @@
|
||||
])),
|
||||
el('tbody', {}, rowsAndEditors),
|
||||
])
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
|
||||
|
||||
// ── NFS section ──
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
|
||||
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const nfsVer = el('select', {}, [
|
||||
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
|
||||
el('option', {value:'v3'}, 'NFSv3'),
|
||||
]);
|
||||
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
|
||||
const addNfs = el('button', {onclick: async () => {
|
||||
if (!nfsServer.value || !nfsExport.value) {
|
||||
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
|
||||
// ── SMB shares section (v0.4.65) ──
|
||||
// Replaces the kernel-mount NFS card. SMB shares are consumed
|
||||
// in userspace via Samba's `smbclient` CLI — no kernel modules,
|
||||
// no CAP_SYS_ADMIN, works in any container. This is the same
|
||||
// approach Bootimus uses.
|
||||
const smbMsg = el('div', {class:'msg'});
|
||||
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
|
||||
const smbShare = el('input', {type:'text', placeholder:'isos'});
|
||||
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
|
||||
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
|
||||
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
|
||||
// Toggle username/password fields based on the Guest checkbox so
|
||||
// operators don't get confused about which fields matter.
|
||||
const syncAuthDisabled = () => {
|
||||
smbUser.disabled = smbGuest.checked;
|
||||
smbPass.disabled = smbGuest.checked;
|
||||
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||||
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
|
||||
};
|
||||
smbGuest.addEventListener('change', syncAuthDisabled);
|
||||
syncAuthDisabled();
|
||||
|
||||
const addSmb = el('button', {onclick: async () => {
|
||||
if (!smbServer.value || !smbShare.value) {
|
||||
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||||
smbMsg.className='msg err'; return;
|
||||
}
|
||||
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs', {
|
||||
server: nfsServer.value, export: nfsExport.value,
|
||||
version: nfsVer.value, read_only: nfsRo.checked,
|
||||
});
|
||||
if (!smbGuest.checked && !smbUser.value) {
|
||||
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
|
||||
smbMsg.className='msg err'; return;
|
||||
}
|
||||
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
smbMsg.className = 'msg';
|
||||
const body = {
|
||||
server: smbServer.value,
|
||||
share: smbShare.value,
|
||||
guest: smbGuest.checked,
|
||||
};
|
||||
if (!smbGuest.checked) {
|
||||
body.username = smbUser.value;
|
||||
body.password = smbPass.value;
|
||||
}
|
||||
const r = await postJSON('/api/smb-shares', body);
|
||||
if (r.ok) {
|
||||
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
|
||||
smbMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
smbMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
const t = await r.text();
|
||||
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
|
||||
// The API returns a structured {error, stderr, hint} JSON
|
||||
// body on failure so the raw smbclient error and the
|
||||
// actionable hint render as two distinct lines.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
}}, 'Mount share');
|
||||
smbMsg.replaceChildren(...parts);
|
||||
smbMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
|
||||
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
||||
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
|
||||
el('div', {class:'meta'},
|
||||
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
|
||||
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
|
||||
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
|
||||
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
if (r.ok) render('storage');
|
||||
}}, 'Re-scan'),
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
|
||||
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
|
||||
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Unmount'),
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
||||
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the
|
||||
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
||||
// 95% so the operator sees the runway shrinking before uploads
|
||||
// start failing with ENOSPC.
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
const diskCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0
|
||||
? (pctUsed.toFixed(1) + '% used')
|
||||
: 'unavailable'),
|
||||
// ── NFS shares section (v0.4.67) ──
|
||||
// Parallel to SMB shares above. The NFSv3 client is in-process
|
||||
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
|
||||
// requests — SMB-sourced ones don't (smbclient CLI can't seek
|
||||
// mid-stream). Otherwise the UX is identical: server + export,
|
||||
// submit, scan, remove.
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className = 'msg err'; return;
|
||||
}
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs-shares', {
|
||||
server: nfsServerIn.value,
|
||||
export: nfsExportIn.value,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
// Structured {error, stderr, hint} same as SMB.
|
||||
let bodyJson = null;
|
||||
let raw = null;
|
||||
try { bodyJson = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
|
||||
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
|
||||
const hint = bodyJson && bodyJson.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Connect failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
nfsMsg.replaceChildren(...parts);
|
||||
nfsMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Add share');
|
||||
|
||||
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
||||
el('div', {class:'meta'},
|
||||
'NFSv3 · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
if (r.ok) render('storage');
|
||||
}}, 'Re-scan'),
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||||
await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
|
||||
|
||||
const diskCard = diskSpaceCard(disk);
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
@@ -709,34 +851,75 @@
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, mounts.length + ' configured'),
|
||||
el('h2', {}, 'SMB shares'),
|
||||
el('span', {class:'sub'}, shares.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'SMB server'),
|
||||
smbServer,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Share name'),
|
||||
smbShare,
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||
el('label', {class:'check'}, [
|
||||
smbGuest, el('span', {}, 'Guest (anonymous read)'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Username'),
|
||||
smbUser,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Password'),
|
||||
smbPass,
|
||||
]),
|
||||
]),
|
||||
addSmb, smbMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'SMB shares are read in userspace via Samba’s smbclient — ' +
|
||||
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
|
||||
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
|
||||
'appliances expose ISO libraries as guest-readable; check the box ' +
|
||||
'above when that’s the case. ISOs are streamed on demand at PXE ' +
|
||||
'boot time — no local cache, no double disk usage.'),
|
||||
]),
|
||||
]),
|
||||
// v0.4.67: NFS shares card sits right below SMB so operators
|
||||
// can see both protocols at a glance. The form is simpler
|
||||
// (no auth) because NFSv3 access control is by client IP on
|
||||
// the server side, not by client-supplied credentials.
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, nfsShares.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'NFS server'),
|
||||
nfsServer,
|
||||
nfsServerIn,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Export path'),
|
||||
nfsExport,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Version'),
|
||||
nfsVer,
|
||||
]),
|
||||
el('label', {class:'check', style:'margin-top:18px'}, [
|
||||
nfsRo, el('span', {}, 'Read-only'),
|
||||
nfsExportIn,
|
||||
]),
|
||||
]),
|
||||
addNfs, nfsMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
|
||||
'mount.nfs binary (bundled in the default Docker image). On ' +
|
||||
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
|
||||
'NFS mounts as a CSI driver outside the pod.'),
|
||||
'NFSv3 shares are read in-process via a pure-Rust client — ' +
|
||||
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
|
||||
'every container the SMB path works in (Unraid included). ' +
|
||||
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
|
||||
'by allowing this OpenPXE host’s IP in the export list. ' +
|
||||
'ISOs are streamed on demand and HTTP Range requests work — ' +
|
||||
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
|
||||
'into a 5 GB ISO without reading what comes before.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
@@ -790,18 +973,37 @@
|
||||
}
|
||||
}}, 'Bind MAC to target');
|
||||
|
||||
const rows = hosts.map(h => el('tr', {}, [
|
||||
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'},
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
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')),
|
||||
]));
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
const table = hosts.length
|
||||
? el('table', {}, [
|
||||
@@ -1002,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:'',
|
||||
@@ -1025,7 +1226,11 @@
|
||||
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
|
||||
placeholder: 'confirm new password'});
|
||||
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||||
const accountSave = el('button', {onclick: async () => {
|
||||
// v0.4.63: explicit top margin so the action button sits clearly
|
||||
// beneath the input row instead of butting against the password
|
||||
// fields. Mirrors the `Save SSO settings` button below for visual
|
||||
// parity between the two settings cards.
|
||||
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
|
||||
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
||||
if (!currentPw.value) {
|
||||
accountMsg.textContent = 'Current password is required.';
|
||||
@@ -1130,29 +1335,39 @@
|
||||
el('option', {value:'xml'}, 'Metadata XML'),
|
||||
]);
|
||||
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
|
||||
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
|
||||
// grid as column 4, so the SSO row is column-for-column aligned with
|
||||
// the Administrator account row above. When the operator switches
|
||||
// to XML mode, column 4 collapses (display:none) and the multi-line
|
||||
// XML textarea takes its own full-width row below — there's no way
|
||||
// to fit a 6-row textarea into a single grid cell without making
|
||||
// the rest of the row look stretched.
|
||||
const urlWrap = el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP metadata URL'),
|
||||
ssoUrl,
|
||||
el('span', {class:'hint'},
|
||||
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.6 just stores it.'),
|
||||
]);
|
||||
const xmlWrap = el('label', {class:'field'}, [
|
||||
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
|
||||
el('span', {class:'name'}, 'IdP metadata XML'),
|
||||
ssoXml,
|
||||
el('span', {class:'hint'},
|
||||
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
|
||||
]);
|
||||
// Hint that used to live under the URL field; surfaced once below
|
||||
// the whole row so it doesn't compete with the in-grid layout.
|
||||
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
|
||||
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
|
||||
const refreshSsoFields = () => {
|
||||
if (ssoMode.value === 'url') {
|
||||
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
|
||||
urlHint.style.display = '';
|
||||
} else {
|
||||
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
|
||||
urlHint.style.display = 'none';
|
||||
}
|
||||
};
|
||||
ssoMode.onchange = refreshSsoFields;
|
||||
refreshSsoFields();
|
||||
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||||
const ssoSave = el('button', {onclick: async () => {
|
||||
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
||||
const payload = {
|
||||
enabled: ssoEnabled.checked,
|
||||
@@ -1193,32 +1408,35 @@
|
||||
ssoEnabled,
|
||||
el('span', {}, 'Enable single sign-on'),
|
||||
]),
|
||||
// 3-column header strip: display name, logo URL, metadata
|
||||
// source. All three controls inherit the same border/padding/
|
||||
// focus chrome from the global `label.field input/select`
|
||||
// rule, so they line up cleanly. Below: the active source
|
||||
// field (URL or XML) spans the full width.
|
||||
el('div', {class:'form-row cols-3'}, [
|
||||
// v0.4.63: 4-column form-row that matches the Administrator
|
||||
// account card above column-for-column — display name / logo
|
||||
// URL / metadata source / metadata URL. All four controls share
|
||||
// the same `label.field` chrome so they line up cleanly. When
|
||||
// the operator picks "Metadata XML" the URL column collapses
|
||||
// and the multi-line textarea drops below the row.
|
||||
el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP display name'),
|
||||
ssoName,
|
||||
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP logo URL'),
|
||||
ssoLogo,
|
||||
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Metadata source'),
|
||||
ssoMode,
|
||||
]),
|
||||
]),
|
||||
urlWrap,
|
||||
]),
|
||||
xmlWrap,
|
||||
urlHint,
|
||||
ssoSave, ssoMsg,
|
||||
]),
|
||||
]);
|
||||
// Wire up + paint the initial visibility now that all elements
|
||||
// referenced by `refreshSsoFields` are attached.
|
||||
refreshSsoFields();
|
||||
|
||||
// ── Custom logo upload.
|
||||
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
||||
@@ -1289,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', {}, [
|
||||
@@ -1315,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'},
|
||||
@@ -1335,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 ' +
|
||||
@@ -1347,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]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1359,6 +1770,7 @@
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
advanced: 'Advanced',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
@@ -1463,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) {
|
||||
@@ -1519,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'}, [
|
||||
@@ -1587,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'}, [
|
||||
@@ -1706,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.'),
|
||||
])));
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
on the asset handlers, the practical caching window is one
|
||||
version. -->
|
||||
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
|
||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
||||
dark→light flash on every navigation. Falls back to the OS
|
||||
preference and finally to dark. -->
|
||||
@@ -32,8 +32,8 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
|
||||
<div class="{{BRAND_CLASS}}">
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
|
||||
<strong>OpenPXE</strong>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -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">
|
||||
@@ -66,7 +67,7 @@
|
||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||
identity is always visible regardless of branding. -->
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.61</span></div>
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.63</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
+24
-1
@@ -17,11 +17,34 @@
|
||||
/// when we know the new one is incompatible. Combined with
|
||||
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
|
||||
/// the worst-case caching window is one version.
|
||||
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
|
||||
/// extra `&r=…` token. Unlike `asset_version` it changes every time
|
||||
/// the operator swaps the custom logo, so the top-left mark updates
|
||||
/// immediately on the next page load instead of being pinned to the
|
||||
/// 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) -> 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]
|
||||
|
||||
+62
-16
@@ -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
|
||||
@@ -81,23 +115,30 @@ FROM debian:12-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates libcap2-bin tini gosu iproute2 \
|
||||
wimtools samba nfs-common \
|
||||
wimtools samba smbclient \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
|
||||
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
|
||||
&& chown -R openpxe:openpxe /var/lib/openpxe
|
||||
# v0.4.5: the openpxe binary itself is now built against musl and is
|
||||
# fully static — no glibc dependency. The runtime stage still ships
|
||||
# Debian slim because OpenPXE shells out to the four packages below for
|
||||
# Debian slim because OpenPXE shells out to the packages below for
|
||||
# functionality we deliberately don't reimplement in-process:
|
||||
#
|
||||
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
|
||||
# samba - `smbd` serves extracted Windows install media on :445 so
|
||||
# WinPE can `net use`. Guest read-only, scoped to
|
||||
# /var/lib/openpxe/smb.
|
||||
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
|
||||
# share manager. Mount requires CAP_SYS_ADMIN; without it
|
||||
# mount(2) returns EPERM and the manager surfaces a clear
|
||||
# error in the UI.
|
||||
# /var/lib/openpxe/smb. This package provides the SERVER
|
||||
# side only; the client CLI is a separate package below.
|
||||
# smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
|
||||
# the Storage tab's SMB shares manager to list and stream
|
||||
# ISOs from remote SMB servers without ever mounting them
|
||||
# in the kernel. In Debian 12 `smbclient` is NOT pulled
|
||||
# in by the `samba` package — they're siblings, not
|
||||
# parent/child. v0.4.65 shipped without this line and
|
||||
# every "Add share" attempt surfaced
|
||||
# `could not exec smbclient: No such file or directory`
|
||||
# until this landed.
|
||||
# iproute2 - `ip addr` / `ip route` for the auto-detected Network
|
||||
# tab fields (NIC name, subnet mask, default gateway).
|
||||
# Tiny, always available; we don't pull in netlink crates
|
||||
@@ -105,8 +146,13 @@ RUN apt-get update \
|
||||
# gosu - drops privileges cleanly from root after the entrypoint
|
||||
# fixes bind-mount ownership (common OpenShift/Docker UX
|
||||
# issue).
|
||||
#
|
||||
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
|
||||
# shares replacement uses userspace `smbclient` and needs no kernel
|
||||
# helpers.
|
||||
#
|
||||
# A future "openpxe-static" variant could drop everything except the
|
||||
# binary onto distroless once we move the Windows + NFS legs to
|
||||
# binary onto distroless once we move the Windows + SMB legs to
|
||||
# in-process Rust crates.
|
||||
|
||||
COPY --from=build /openpxe /usr/local/bin/openpxe
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
# PXE menu theme — research for next-release follow-up
|
||||
|
||||
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
|
||||
compositor wired; this note captures the design for the menu-theming work
|
||||
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
|
||||
|
||||
## How iVentoy actually does it
|
||||
|
||||
iVentoy is closed-source for its menu, but the supporting bits are
|
||||
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
|
||||
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
|
||||
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
|
||||
`iventoy_loader_16000_uefi`).
|
||||
|
||||
The graphical menu itself is rendered by iPXE's framebuffer console
|
||||
with a baked-in PNG background via `console --picture` — same
|
||||
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
|
||||
Evidence:
|
||||
|
||||
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
|
||||
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
|
||||
needs).
|
||||
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
|
||||
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
|
||||
selectable resolutions from the iVentoy web UI **Configuration tab**,
|
||||
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
|
||||
a resolution-tagged script per boot and serves the matching PNG.
|
||||
- iVentoy docs explicitly state both Free and Pro editions **do not
|
||||
support** modifying the boot background/title — it's baked into the
|
||||
shipped PNG assets.
|
||||
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
|
||||
highlight bar, no borders) painted on top of the PNG, with margins
|
||||
set via `console --left/--right/--top/--bottom` to keep the text off
|
||||
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
|
||||
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
|
||||
GOP despite older folklore.
|
||||
|
||||
Do not conflate this with Ventoy-USB, which is a separate codebase and
|
||||
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
|
||||
|
||||
## Rust ingredients to replicate / surpass
|
||||
|
||||
Most of these already exist in the workspace.
|
||||
|
||||
1. **Compositor (extend, don't replace)** — extend
|
||||
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
|
||||
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
|
||||
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
|
||||
text (subtitle, hostname, version). One source SVG/logo, three to
|
||||
five rendered PNGs cached on disk.
|
||||
2. **Script generator** — `ipxe_script.rs` already emits
|
||||
`console --picture … || console`. Add a `?res=` query param (or
|
||||
per-MAC client hint persisted in `hosts.json`) and serve the matching
|
||||
PNG plus matching `console --x --y` line. Keep the text-console
|
||||
fallback already in place.
|
||||
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
|
||||
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
|
||||
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
|
||||
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
|
||||
parsing is overkill for the first cut.
|
||||
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
|
||||
highlight, no borders). To look distinctly cooler without leaving
|
||||
iPXE: paint border / title / footer **into the PNG**, leave a window
|
||||
in the middle, then `console --left/--right/--top/--bottom` to inset
|
||||
the iPXE menu exactly into that window. ASCII box-drawing inside the
|
||||
menu remains fragile (iPXE mangles non-ASCII on some builds — already
|
||||
noted in `ipxe_script.rs`).
|
||||
|
||||
## Recommended architecture for the next OpenPXE release
|
||||
|
||||
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
|
||||
+ theme tokens (accent colour, title, footer) and renders a layered
|
||||
PNG (background gradient → framing chrome → logo → title bar → footer
|
||||
with `${hostname}` / `${version}` / `${ip}`) at the three target
|
||||
resolutions. Cache by hash of inputs.
|
||||
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
|
||||
Settings dropdown.
|
||||
- In `ipxe_script.rs`, emit
|
||||
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
|
||||
then the existing `menu` / `item` / `choose` block — text now lands
|
||||
inside the framed window.
|
||||
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
|
||||
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
|
||||
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
|
||||
The follow-up will use a Gitea Actions runner pinned to native
|
||||
`linux/amd64` (an Unraid host already exists for this).
|
||||
- Stretch goal: a second "theme pack" that ships a layered PNG with
|
||||
subtle scanlines / grid — iPXE can't animate, but a well-designed
|
||||
static composite beats iVentoy's plain centered logo handily.
|
||||
|
||||
## Source URLs
|
||||
|
||||
- https://github.com/ventoy/PXE
|
||||
- https://github.com/ventoy/PXE/tree/master/iPXE
|
||||
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
|
||||
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
|
||||
- https://ipxe.org/cmd/console — `--picture` and compile flags
|
||||
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
|
||||
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
|
||||
requirement
|
||||
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
|
||||
behaviour
|
||||
- https://www.iventoy.com/en/doc_edition.html — background / title not
|
||||
user-customisable
|
||||
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
|
||||
iVentoy alternative
|
||||
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