Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0afbe860e8 | ||
|
|
9f694f7c79 | ||
|
|
d729a7ae2f | ||
|
|
1419309a2d |
Generated
+187
-8
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -84,6 +90,12 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.7.9"
|
version = "0.7.9"
|
||||||
@@ -212,12 +224,24 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -280,6 +304,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -295,6 +325,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -406,6 +445,25 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -549,6 +607,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "globset"
|
name = "globset"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@@ -821,6 +889,34 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -958,6 +1054,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -969,6 +1075,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -1001,6 +1117,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe"
|
name = "openpxe"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1037,7 +1162,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-core"
|
name = "openpxe-core"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
@@ -1056,7 +1181,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-dhcp-proxy"
|
name = "openpxe-dhcp-proxy"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1070,13 +1195,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-http-api"
|
name = "openpxe-http-api"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"image",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
@@ -1100,7 +1226,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-ipxe-assets"
|
name = "openpxe-ipxe-assets"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -1110,12 +1236,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-iso-store"
|
name = "openpxe-iso-store"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"hex",
|
"hex",
|
||||||
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
"openpxe-core",
|
"openpxe-core",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -1133,7 +1260,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-tftp"
|
name = "openpxe-tftp"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1147,7 +1274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openpxe-webui"
|
name = "openpxe-webui"
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
@@ -1184,6 +1311,19 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1227,6 +1367,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1484,6 +1636,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2124,6 +2282,12 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -2438,3 +2602,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.6"
|
version = "0.4.64"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ tower = { workspace = true }
|
|||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
|
# v0.4.61: integration tests need to generate real PNG bytes for the
|
||||||
|
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
+114
-49
@@ -193,23 +193,47 @@ async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>)
|
|||||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn index(State(state): State<AppState>) -> Response {
|
async fn index(State(state): State<AppState>) -> Response {
|
||||||
let html = openpxe_webui::index_html(&state.public_base_url);
|
// The asset version pin in index.html (`?v=…`) is what makes
|
||||||
|
// 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"));
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
header::CONTENT_TYPE,
|
(
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
header::CONTENT_TYPE,
|
||||||
)],
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
),
|
||||||
|
// index.html itself must never be cached — that's how the
|
||||||
|
// browser learns about a new `?v=…` value for the assets.
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-cache, must-revalidate"),
|
||||||
|
),
|
||||||
|
],
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
|
||||||
|
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
|
||||||
|
/// same bytes on every page-load, but require revalidation — combined
|
||||||
|
/// with the `?v=<version>` query string in index.html, the practical
|
||||||
|
/// upper bound on caching across an upgrade is "until the operator
|
||||||
|
/// reloads".
|
||||||
|
const ASSET_CACHE_CONTROL: HeaderValue =
|
||||||
|
HeaderValue::from_static("no-cache, must-revalidate");
|
||||||
|
|
||||||
async fn ui_js() -> Response {
|
async fn ui_js() -> Response {
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
header::CONTENT_TYPE,
|
(
|
||||||
HeaderValue::from_static("application/javascript"),
|
header::CONTENT_TYPE,
|
||||||
)],
|
HeaderValue::from_static("application/javascript"),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::app_js(),
|
openpxe_webui::app_js(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -217,7 +241,10 @@ async fn ui_js() -> Response {
|
|||||||
|
|
||||||
async fn ui_css() -> Response {
|
async fn ui_css() -> Response {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
[
|
||||||
|
(header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::app_css(),
|
openpxe_webui::app_css(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -261,20 +288,29 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
header::CONTENT_TYPE,
|
(
|
||||||
HeaderValue::from_static("image/svg+xml"),
|
header::CONTENT_TYPE,
|
||||||
)],
|
HeaderValue::from_static("image/svg+xml"),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::logo_svg(),
|
openpxe_webui::logo_svg(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.4.6: raster-only logo endpoint for the iPXE menu's
|
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
|
||||||
/// `console --picture`. iPXE can't rasterize SVG, so SVG uploads 404
|
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
|
||||||
/// here — the ASCII OpenPXE wordmark in `render_menu` already gives
|
/// GIF) of any aspect ratio; this handler decodes it, draws it
|
||||||
/// the operator a polished default. No bundled PNG fallback by design:
|
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
|
||||||
/// either the operator's raster logo paints, or the text stands in.
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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.)
|
||||||
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||||||
let Some(path) = state.branding.logo_path() else {
|
let Some(path) = state.branding.logo_path() else {
|
||||||
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
||||||
@@ -285,40 +321,68 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
|||||||
if mime == "image/svg+xml" {
|
if mime == "image/svg+xml" {
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"operator-uploaded logo is SVG; iPXE menu falls back to the bundled ASCII wordmark",
|
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
match tokio::fs::read(&path).await {
|
let bytes = match tokio::fs::read(&path).await {
|
||||||
Ok(bytes) => {
|
Ok(b) => b,
|
||||||
let ct = HeaderValue::from_str(&mime)
|
Err(e) => {
|
||||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
return (
|
||||||
(
|
StatusCode::NOT_FOUND,
|
||||||
[
|
format!("custom logo unreadable: {e}"),
|
||||||
(header::CONTENT_TYPE, ct),
|
|
||||||
(
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
HeaderValue::from_static("no-cache, max-age=0"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bytes,
|
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => (
|
};
|
||||||
StatusCode::NOT_FOUND,
|
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
|
||||||
format!("custom logo unreadable: {e}"),
|
// centered-top regardless of the operator's source dimensions. The
|
||||||
)
|
// `image` crate is pure-Rust + sync; offload to a blocking task
|
||||||
.into_response(),
|
// because Lanczos resampling on a 4K source can take tens of
|
||||||
}
|
// milliseconds and we don't want to block the executor.
|
||||||
|
let composed =
|
||||||
|
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(png)) => png,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::http::branding",
|
||||||
|
error = %e, "failed to compose PXE logo PNG"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("failed to compose PXE logo: {e}"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("pxe-logo task failed: {e}"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
|
composed,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ui_loader() -> Response {
|
async fn ui_loader() -> Response {
|
||||||
(
|
(
|
||||||
[(
|
[
|
||||||
header::CONTENT_TYPE,
|
(
|
||||||
HeaderValue::from_static("image/svg+xml"),
|
header::CONTENT_TYPE,
|
||||||
)],
|
HeaderValue::from_static("image/svg+xml"),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||||
|
],
|
||||||
openpxe_webui::loader_svg(),
|
openpxe_webui::loader_svg(),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -1649,11 +1713,12 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
|
|||||||
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
||||||
match state.nfs.add(req).await {
|
match state.nfs.add(req).await {
|
||||||
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
||||||
// Anything from the manager surfaces as a user-fixable validation
|
// v0.4.64: the manager returns a structured `NfsMountError` with
|
||||||
// error — bad host, kernel without NFS support, missing
|
// `error` + optional `hint` + the raw `stderr`, so the UI can
|
||||||
// `mount.nfs`, dead server. We pass the message through verbatim
|
// show both — the raw message for completeness, the hint for
|
||||||
// so the UI can show it to the operator.
|
// "what to fix next". Previously this was a plain text body
|
||||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
// which collapsed both bits of information into one line.
|
||||||
|
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,10 +77,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
|||||||
);
|
);
|
||||||
let _ = writeln!(s, ":menu");
|
let _ = writeln!(s, ":menu");
|
||||||
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
||||||
// Centered ASCII wordmark. iPXE menus are ~76 columns wide on the
|
// ASCII OpenPXE wordmark. Works on every iPXE build, including
|
||||||
// default VGA text console; the lines below are padded to sit
|
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
|
||||||
// approximately centered. `item --gap -- <text>` emits text without
|
// so `console --picture` paints nothing). When the queued iPXE
|
||||||
// a selectable hotkey.
|
// 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 -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
|
||||||
@@ -628,10 +631,13 @@ mod password_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_menu_has_polished_branding_and_arch_footer() {
|
fn top_menu_has_polished_branding_and_arch_footer() {
|
||||||
// v0.4.6 polish: a `console --picture` line for operator
|
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
|
||||||
// logos, an ASCII OpenPXE wordmark visible across iPXE
|
// `console --picture` line that PNG-capable iPXE builds will
|
||||||
// builds (graphics or not), and a single-line footer carrying
|
// honour (queued for a follow-up release once we can rebuild
|
||||||
// the current OpenPXE version + the resolved arch label.
|
// 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.
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -641,11 +647,11 @@ mod password_tests {
|
|||||||
// Picture-or-text-console must be a single statement so older
|
// Picture-or-text-console must be a single statement so older
|
||||||
// iPXE parsers don't choke on the chain.
|
// iPXE parsers don't choke on the chain.
|
||||||
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
||||||
// ASCII wordmark — at least one of the banner lines must
|
// ASCII wordmark — paints on every iPXE build regardless of
|
||||||
// contain the trailing pipe segment, plus the leading "_"s.
|
// PNG support.
|
||||||
assert!(
|
assert!(
|
||||||
s.contains("___ ___ __ __ ___"),
|
s.contains("___ ___ __ __ ___"),
|
||||||
"ascii banner missing first row:\n{s}"
|
"ASCII banner missing first row:\n{s}"
|
||||||
);
|
);
|
||||||
// Footer with version + arch interpolation. The version comes
|
// Footer with version + arch interpolation. The version comes
|
||||||
// from CARGO_PKG_VERSION at compile time.
|
// from CARGO_PKG_VERSION at compile time.
|
||||||
|
|||||||
@@ -321,10 +321,25 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|||||||
export: export.to_string(),
|
export: export.to_string(),
|
||||||
version,
|
version,
|
||||||
read_only,
|
read_only,
|
||||||
|
// v0.4.64: terminal callers can't override the port yet
|
||||||
|
// — keep the default 2049. We could plumb a 4th arg
|
||||||
|
// later if anyone asks.
|
||||||
|
port: None,
|
||||||
};
|
};
|
||||||
match s.nfs.add(req).await {
|
match s.nfs.add(req).await {
|
||||||
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
|
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
|
||||||
Err(e) => Err(format!("mount failed: {e}")),
|
// v0.4.64: `add` now returns a structured `NfsMountError`.
|
||||||
|
// We render the raw error plus the hint (if any) on
|
||||||
|
// separate lines so the terminal output mirrors what
|
||||||
|
// the Storage tab shows.
|
||||||
|
Err(e) => {
|
||||||
|
let mut out = format!("mount failed: {}", e.error);
|
||||||
|
if let Some(h) = e.hint {
|
||||||
|
out.push_str("\nhint: ");
|
||||||
|
out.push_str(&h);
|
||||||
|
}
|
||||||
|
Err(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("unmount") => {
|
Some("unmount") => {
|
||||||
|
|||||||
@@ -1755,11 +1755,26 @@ async fn pxe_logo_404_when_no_custom_logo_configured() {
|
|||||||
assert!(text.contains("no custom logo"), "got: {text}");
|
assert!(text.contains("no custom logo"), "got: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
|
||||||
|
/// compositor decodes whatever the operator uploaded — hand-rolled
|
||||||
|
/// PNGs with handwritten CRCs are too easy to break; let the encoder
|
||||||
|
/// produce something it can later decode.
|
||||||
|
fn tiny_png() -> Vec<u8> {
|
||||||
|
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
|
||||||
|
use std::io::Cursor;
|
||||||
|
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220]));
|
||||||
|
let mut out = Vec::with_capacity(256);
|
||||||
|
DynamicImage::ImageRgb8(buf)
|
||||||
|
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
||||||
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
||||||
// light up the PXE menu's `console --picture` overlay — the ASCII
|
// light up the PXE menu's `console --picture` overlay — the menu
|
||||||
// wordmark in render_menu stands in instead.
|
// simply paints without a logo.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
@@ -1777,11 +1792,15 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pxe_logo_serves_raster_with_correct_mime() {
|
async fn pxe_logo_composes_to_1024x768_png() {
|
||||||
|
// v0.4.61: the endpoint no longer serves the raw upload — it
|
||||||
|
// composes the operator's logo into a fixed 1024×768 canvas so
|
||||||
|
// the iPXE menu always paints at consistent dimensions.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
let png = tiny_png();
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake-png-bytes")
|
.set_logo("image/png", "png", &png)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
let res = app
|
let res = app
|
||||||
@@ -1805,7 +1824,14 @@ async fn pxe_logo_serves_raster_with_correct_mime() {
|
|||||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// PNG signature.
|
||||||
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
|
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
|
||||||
|
// IHDR chunk lives at bytes 8..29; width is bytes 16..20, height
|
||||||
|
// 20..24 in big-endian u32. The composed canvas should be 1024×768.
|
||||||
|
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
|
||||||
|
let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
|
||||||
|
assert_eq!(width, 1024, "compose should pin width to 1024");
|
||||||
|
assert_eq!(height, 768, "compose should pin height to 768");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1814,9 +1840,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
|||||||
// must stay reachable once the admin has been bootstrapped. The
|
// must stay reachable once the admin has been bootstrapped. The
|
||||||
// auth allowlist gates `/api/*` only.
|
// auth allowlist gates `/api/*` only.
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
let png = tiny_png();
|
||||||
state
|
state
|
||||||
.branding
|
.branding
|
||||||
.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake")
|
.set_logo("image/png", "png", &png)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
// Configure an admin so the middleware kicks in.
|
// Configure an admin so the middleware kicks in.
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ parking_lot.workspace = true
|
|||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
# v0.4.61: server-side compose of the operator's uploaded raster into a
|
||||||
|
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
|
||||||
|
# PNG regardless of what the operator uploaded. We use the bare-bones
|
||||||
|
# `image` crate (no default features) and explicitly enable only the
|
||||||
|
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
|
||||||
|
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.12"
|
tempfile = "3.12"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod introspect;
|
pub mod introspect;
|
||||||
pub mod nfs;
|
pub mod nfs;
|
||||||
|
pub mod pxe_logo;
|
||||||
pub mod smb;
|
pub mod smb;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
+521
-95
@@ -9,14 +9,15 @@
|
|||||||
//! 1. Operator submits a mount spec via the Storage tab:
|
//! 1. Operator submits a mount spec via the Storage tab:
|
||||||
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
|
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
|
||||||
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
|
//! 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`.
|
//! to `mount.nfs -v -o vers=...,ro,nolock,proto=tcp server:export local`.
|
||||||
//! 3. On success we walk the mount point looking for `*.iso` files and
|
//! 3. On success we walk the mount point looking for `*.iso` files and
|
||||||
//! register each one with the `IsoStore` as an external source — same
|
//! register each one with the `IsoStore` as an external source — same
|
||||||
//! introspection pipeline as a web upload, but no sha256 (the bytes
|
//! introspection pipeline as a web upload, but no sha256 (the bytes
|
||||||
//! live on a remote machine; hashing them would suck them through the
|
//! live on a remote machine; hashing them would suck them through the
|
||||||
//! network on every restart).
|
//! network on every restart).
|
||||||
//! 4. On failure we record `last_error` on the spec and persist anyway
|
//! 4. On failure we record `last_error` + `hint` on the spec and persist
|
||||||
//! so the UI can show a row in red rather than silently dropping it.
|
//! anyway so the UI can show a row in red with an actionable hint
|
||||||
|
//! rather than silently dropping it.
|
||||||
//!
|
//!
|
||||||
//! ## Operational notes
|
//! ## Operational notes
|
||||||
//!
|
//!
|
||||||
@@ -28,6 +29,28 @@
|
|||||||
//! - Mount commands are issued sequentially under a single mutex to avoid
|
//! - Mount commands are issued sequentially under a single mutex to avoid
|
||||||
//! `mount` racing on the same target dir.
|
//! `mount` racing on the same target dir.
|
||||||
//!
|
//!
|
||||||
|
//! ## v0.4.64 diagnostics rework
|
||||||
|
//!
|
||||||
|
//! Field reports showed `mount.nfs: failed to apply fstab options` (exit
|
||||||
|
//! code 32) was the dominant failure surfaced through the UI — a deeply
|
||||||
|
//! unhelpful message from nfs-utils 2.6.x that has nothing to do with
|
||||||
|
//! `/etc/fstab`. It comes from `nfs_options2string()` and lights up when
|
||||||
|
//! the kernel can't accept the assembled options, when mtab can't be
|
||||||
|
//! written (container without `CAP_SYS_ADMIN`), or when an obscure option
|
||||||
|
//! triggers a transformation edge case. In v0.4.64 we:
|
||||||
|
//!
|
||||||
|
//! 1. Probe TCP reach to `server:port` before shelling out so a wrong
|
||||||
|
//! IP / closed firewall surfaces as a clear "cannot reach NFS port"
|
||||||
|
//! instead of `failed to apply fstab options`.
|
||||||
|
//! 2. Pass `proto=tcp` explicitly on NFSv3 (UDP is widely deprecated
|
||||||
|
//! and several NAS appliances don't bind it at all).
|
||||||
|
//! 3. On `failed to apply fstab options`, retry with a stripped-down
|
||||||
|
//! option set (`vers=N,ro/rw`) — that frequently succeeds and at
|
||||||
|
//! minimum produces a real kernel error.
|
||||||
|
//! 4. Translate well-known stderr patterns into operator-friendly hints
|
||||||
|
//! and persist them on the mount so the UI can show "what to fix
|
||||||
|
//! next" instead of the raw mount.nfs message.
|
||||||
|
//!
|
||||||
//! ## Persistence
|
//! ## Persistence
|
||||||
//!
|
//!
|
||||||
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
|
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
|
||||||
@@ -42,9 +65,20 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// Default port for NFS over TCP. We expose it as a constant so the
|
||||||
|
/// pre-flight probe and the option string assembly use the same value.
|
||||||
|
const DEFAULT_NFS_PORT: u16 = 2049;
|
||||||
|
|
||||||
|
/// How long to wait for a TCP connection to the NFS server before
|
||||||
|
/// declaring it unreachable. Short enough that a wrong IP doesn't make
|
||||||
|
/// the UI hang for half a minute; long enough that a slow appliance
|
||||||
|
/// can still answer.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
/// Wire-protocol versions we support. Keep this enum closed — silently
|
/// Wire-protocol versions we support. Keep this enum closed — silently
|
||||||
/// accepting "auto" or letting the kernel negotiate would mean operators
|
/// accepting "auto" or letting the kernel negotiate would mean operators
|
||||||
/// could never confirm which version is in use.
|
/// could never confirm which version is in use.
|
||||||
@@ -65,6 +99,14 @@ impl NfsVersion {
|
|||||||
Self::V41 => "vers=4.1",
|
Self::V41 => "vers=4.1",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short label for UI surfaces and log lines.
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::V3 => "NFSv3",
|
||||||
|
Self::V41 => "NFSv4.1",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One configured mount. The id is generated from server+export so the
|
/// One configured mount. The id is generated from server+export so the
|
||||||
@@ -78,6 +120,12 @@ pub struct NfsMount {
|
|||||||
/// Read-only by default — most ISO libraries are. Operators that need
|
/// Read-only by default — most ISO libraries are. Operators that need
|
||||||
/// write can flip this off but OpenPXE itself never writes.
|
/// write can flip this off but OpenPXE itself never writes.
|
||||||
pub read_only: bool,
|
pub read_only: bool,
|
||||||
|
/// TCP port for the NFS service. Defaults to 2049; configurable for
|
||||||
|
/// the (rare) case where the appliance binds the service elsewhere.
|
||||||
|
/// v0.4.64: previously inferred at runtime; now persisted so the UI
|
||||||
|
/// can echo the value back to the operator.
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
/// Local mount point under `<work_dir>/nfs/`.
|
/// Local mount point under `<work_dir>/nfs/`.
|
||||||
pub local_path: PathBuf,
|
pub local_path: PathBuf,
|
||||||
/// Whether the mount is currently active.
|
/// Whether the mount is currently active.
|
||||||
@@ -85,6 +133,12 @@ pub struct NfsMount {
|
|||||||
/// Last error encountered on a `mount` or `umount` attempt; cleared on
|
/// Last error encountered on a `mount` or `umount` attempt; cleared on
|
||||||
/// success.
|
/// success.
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
|
/// v0.4.64: operator-friendly translation of `last_error` — e.g. for
|
||||||
|
/// "failed to apply fstab options" we surface "CAP_SYS_ADMIN may be
|
||||||
|
/// missing on the container". `None` means we don't have a friendlier
|
||||||
|
/// rendition than the raw error.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_hint: Option<String>,
|
||||||
#[serde(with = "time::serde::rfc3339::option")]
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
pub last_attempt: Option<OffsetDateTime>,
|
pub last_attempt: Option<OffsetDateTime>,
|
||||||
/// Number of `.iso` files found on the share (re-counted on each scan).
|
/// Number of `.iso` files found on the share (re-counted on each scan).
|
||||||
@@ -100,6 +154,9 @@ pub struct NfsAddRequest {
|
|||||||
pub version: NfsVersion,
|
pub version: NfsVersion,
|
||||||
#[serde(default = "default_ro")]
|
#[serde(default = "default_ro")]
|
||||||
pub read_only: bool,
|
pub read_only: bool,
|
||||||
|
/// Optional TCP port — defaults to 2049 if omitted or zero.
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> NfsVersion {
|
fn default_version() -> NfsVersion {
|
||||||
@@ -108,6 +165,35 @@ fn default_version() -> NfsVersion {
|
|||||||
fn default_ro() -> bool {
|
fn default_ro() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
DEFAULT_NFS_PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of an `add` attempt. `Ok` carries the mount; `Err` from the
|
||||||
|
/// API layer is converted to this richer shape so the UI can render the
|
||||||
|
/// raw error and the actionable hint independently.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct NfsMountError {
|
||||||
|
/// The first line / summary of what went wrong.
|
||||||
|
pub error: String,
|
||||||
|
/// Verbatim stderr from `mount.nfs` (trimmed). May be empty.
|
||||||
|
pub stderr: String,
|
||||||
|
/// Operator-friendly hint or `None` if we don't have one.
|
||||||
|
pub hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NfsMountError {
|
||||||
|
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
|
||||||
|
let stderr = stderr.into();
|
||||||
|
let error = error.into();
|
||||||
|
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
|
||||||
|
Self {
|
||||||
|
error,
|
||||||
|
stderr,
|
||||||
|
hint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Inner {
|
struct Inner {
|
||||||
@@ -165,6 +251,7 @@ impl NfsManager {
|
|||||||
// when the process died. We'll try to remount each one.
|
// when the process died. We'll try to remount each one.
|
||||||
m.mounted = false;
|
m.mounted = false;
|
||||||
m.last_error = None;
|
m.last_error = None;
|
||||||
|
m.last_hint = None;
|
||||||
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
|
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
|
||||||
if let Err(e) = self.try_mount(&m.id).await {
|
if let Err(e) = self.try_mount(&m.id).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -178,20 +265,42 @@ impl NfsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
|
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
|
||||||
/// reflecting reality) or an error if the spec was invalid.
|
/// reflecting reality) or a structured `NfsMountError` describing
|
||||||
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
|
/// what went wrong.
|
||||||
let server = req.server.trim().to_string();
|
pub async fn add(
|
||||||
|
&self,
|
||||||
|
req: NfsAddRequest,
|
||||||
|
) -> std::result::Result<NfsMount, NfsMountError> {
|
||||||
|
let server = normalize_server(&req.server);
|
||||||
let export = req.export.trim().to_string();
|
let export = req.export.trim().to_string();
|
||||||
if server.is_empty() {
|
if server.is_empty() {
|
||||||
return Err(Error::Invalid("server is required".into()));
|
return Err(NfsMountError::from_raw(
|
||||||
|
"server is required",
|
||||||
|
"",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !export.starts_with('/') {
|
if !export.starts_with('/') {
|
||||||
return Err(Error::Invalid("export path must start with '/'".into()));
|
return Err(NfsMountError::from_raw(
|
||||||
|
"export path must start with '/'",
|
||||||
|
"",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
if export.contains('\0') || server.contains('\0') {
|
||||||
|
return Err(NfsMountError::from_raw(
|
||||||
|
"server / export must not contain NUL bytes",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
|
||||||
|
|
||||||
let id = mount_id(&server, &export);
|
let id = mount_id(&server, &export);
|
||||||
let local_path = self.work_root.join(&id);
|
let local_path = self.work_root.join(&id);
|
||||||
tokio::fs::create_dir_all(&local_path).await?;
|
if let Err(e) = tokio::fs::create_dir_all(&local_path).await {
|
||||||
|
return Err(NfsMountError::from_raw(
|
||||||
|
format!("failed to create local mount point: {e}"),
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mount = NfsMount {
|
let mount = NfsMount {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -199,15 +308,28 @@ impl NfsManager {
|
|||||||
export,
|
export,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
read_only: req.read_only,
|
read_only: req.read_only,
|
||||||
|
port,
|
||||||
local_path,
|
local_path,
|
||||||
mounted: false,
|
mounted: false,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
|
last_hint: None,
|
||||||
last_attempt: None,
|
last_attempt: None,
|
||||||
iso_count: 0,
|
iso_count: 0,
|
||||||
};
|
};
|
||||||
self.inner.lock().mounts.insert(id.clone(), mount);
|
self.inner.lock().mounts.insert(id.clone(), mount);
|
||||||
self.persist_locked();
|
self.persist_locked();
|
||||||
self.try_mount(&id).await?;
|
self.try_mount(&id).await.map_err(|e| {
|
||||||
|
// try_mount has already persisted last_error/last_hint. We
|
||||||
|
// refetch them so the API response reflects exactly what the
|
||||||
|
// UI will see when it lists mounts.
|
||||||
|
let m = self.get(&id);
|
||||||
|
NfsMountError {
|
||||||
|
error: m.as_ref().and_then(|m| m.last_error.clone())
|
||||||
|
.unwrap_or_else(|| e.to_string()),
|
||||||
|
stderr: String::new(),
|
||||||
|
hint: m.and_then(|m| m.last_hint),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
Ok(self.get(&id).expect("mount just inserted"))
|
Ok(self.get(&id).expect("mount just inserted"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,63 +398,113 @@ impl NfsManager {
|
|||||||
// Already mounted? Skip — `mount` would error on a busy target
|
// Already mounted? Skip — `mount` would error on a busy target
|
||||||
// and confuse the operator's UI status.
|
// and confuse the operator's UI status.
|
||||||
if is_mountpoint(&m.local_path).await {
|
if is_mountpoint(&m.local_path).await {
|
||||||
self.update_status(id, true, None, now);
|
self.update_status(id, true, None, None, now);
|
||||||
// Even though already mounted, we still want a fresh ISO count.
|
// Even though already mounted, we still want a fresh ISO count.
|
||||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||||
self.update_iso_count(id, count);
|
self.update_iso_count(id, count);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let opts = mount_options(&m);
|
// v0.4.64: pre-flight TCP probe. Catches the dominant failure
|
||||||
let target = format!("{}:{}", m.server, m.export);
|
// mode (wrong IP / firewall) before mount.nfs gets a chance to
|
||||||
|
// emit its unhelpful "failed to apply fstab options" message.
|
||||||
let output = Command::new("mount")
|
if let Err((err, hint)) = tcp_probe(&m.server, m.port).await {
|
||||||
.arg("-t")
|
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
|
||||||
.arg("nfs")
|
self.update_status(id, false, Some(err.clone()), Some(hint), now);
|
||||||
.arg("-o")
|
return Err(Error::Invalid(err));
|
||||||
.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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First attempt: full option set.
|
||||||
|
let full_opts = mount_options(&m, /*minimal*/ false);
|
||||||
|
let target = format!("{}:{}", m.server, m.export);
|
||||||
|
let attempt = run_mount_nfs(&full_opts, &target, &m.local_path).await;
|
||||||
|
|
||||||
|
let (success, stderr, exit_code) = match attempt {
|
||||||
|
Ok((true, stderr, _)) => (true, stderr, 0),
|
||||||
|
Ok((false, stderr, code)) => (false, stderr, code),
|
||||||
|
Err(e) => {
|
||||||
|
let err = format!("could not exec mount(8): {e}");
|
||||||
|
let hint = Some(
|
||||||
|
"the runtime image is missing /bin/mount or nfs-common — \
|
||||||
|
verify the container hasn't been stripped down"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
|
||||||
|
self.update_status(id, false, Some(err.clone()), hint, now);
|
||||||
|
return Err(Error::Invalid(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if success {
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::nfs",
|
||||||
|
id = %id, server = %m.server, export = %m.export,
|
||||||
|
version = %m.version.label(), port = m.port,
|
||||||
|
"NFS mount succeeded"
|
||||||
|
);
|
||||||
|
self.update_status(id, true, None, None, now);
|
||||||
|
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||||
|
self.update_iso_count(id, count);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second attempt: if the first attempt failed with the
|
||||||
|
// "failed to apply fstab options" oddity, retry with a minimal
|
||||||
|
// option set. nfs-utils 2.6.x sometimes chokes on the assembled
|
||||||
|
// option string for reasons unrelated to the actual options
|
||||||
|
// being valid; the stripped form bypasses the transformation
|
||||||
|
// edge case.
|
||||||
|
let trigger_retry = looks_like_option_transform_failure(&stderr);
|
||||||
|
let (final_success, final_stderr, final_exit_code) = if trigger_retry {
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::nfs", id = %id,
|
||||||
|
"retrying with minimal options after option-transform failure"
|
||||||
|
);
|
||||||
|
let minimal = mount_options(&m, /*minimal*/ true);
|
||||||
|
match run_mount_nfs(&minimal, &target, &m.local_path).await {
|
||||||
|
Ok((true, s, _)) => (true, s, 0),
|
||||||
|
Ok((false, s, c)) => (false, s, c),
|
||||||
|
Err(e) => (false, format!("could not exec mount(8): {e}"), -1),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(false, stderr, exit_code)
|
||||||
|
};
|
||||||
|
|
||||||
|
if final_success {
|
||||||
|
tracing::info!(
|
||||||
|
target: "openpxe::nfs", id = %id,
|
||||||
|
"NFS mount succeeded on minimal-options retry"
|
||||||
|
);
|
||||||
|
self.update_status(id, true, None, None, now);
|
||||||
|
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||||
|
self.update_iso_count(id, count);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure path: persist a clear error and a hint, log both.
|
||||||
|
// `mount(8)` passes mount.nfs's stderr through verbatim, so the
|
||||||
|
// user-visible text reads like "mount.nfs: ..." — we prepend the
|
||||||
|
// exit code so the operator can tell at a glance that the helper
|
||||||
|
// ran but rejected the request, vs the helper not running at all.
|
||||||
|
let err = if final_stderr.is_empty() {
|
||||||
|
format!("mount exit {final_exit_code}")
|
||||||
|
} else {
|
||||||
|
format!("mount exit {final_exit_code}: {}", final_stderr.trim())
|
||||||
|
};
|
||||||
|
let hint = hint_for(&final_stderr);
|
||||||
|
tracing::warn!(
|
||||||
|
target: "openpxe::nfs", id = %id,
|
||||||
|
hint = ?hint, "{err}"
|
||||||
|
);
|
||||||
|
self.update_status(id, false, Some(err.clone()), hint, now);
|
||||||
|
Err(Error::Invalid(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn umount_one(&self, id: &str) -> Result<()> {
|
async fn umount_one(&self, id: &str) -> Result<()> {
|
||||||
let _g = self.mount_lock.lock().await;
|
let _g = self.mount_lock.lock().await;
|
||||||
let Some(m) = self.get(id) else { return Ok(()) };
|
let Some(m) = self.get(id) else { return Ok(()) };
|
||||||
if !is_mountpoint(&m.local_path).await {
|
if !is_mountpoint(&m.local_path).await {
|
||||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// -l = lazy: detach immediately, finish when no process has a
|
// -l = lazy: detach immediately, finish when no process has a
|
||||||
@@ -344,7 +516,7 @@ impl NfsManager {
|
|||||||
.await;
|
.await;
|
||||||
match out {
|
match out {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => {
|
||||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
self.update_status(id, false, None, None, OffsetDateTime::now_utc());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
@@ -353,12 +525,24 @@ impl NfsManager {
|
|||||||
o.status.code().unwrap_or(-1),
|
o.status.code().unwrap_or(-1),
|
||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
);
|
);
|
||||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
self.update_status(
|
||||||
|
id,
|
||||||
|
false,
|
||||||
|
Some(e.clone()),
|
||||||
|
None,
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
);
|
||||||
Err(Error::Invalid(e))
|
Err(Error::Invalid(e))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let e = format!("could not exec /bin/umount: {e}");
|
let e = format!("could not exec /bin/umount: {e}");
|
||||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
self.update_status(
|
||||||
|
id,
|
||||||
|
false,
|
||||||
|
Some(e.clone()),
|
||||||
|
None,
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
);
|
||||||
Err(Error::Invalid(e))
|
Err(Error::Invalid(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,10 +594,18 @@ impl NfsManager {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
|
fn update_status(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
mounted: bool,
|
||||||
|
err: Option<String>,
|
||||||
|
hint: Option<String>,
|
||||||
|
ts: OffsetDateTime,
|
||||||
|
) {
|
||||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||||
m.mounted = mounted;
|
m.mounted = mounted;
|
||||||
m.last_error = err;
|
m.last_error = err;
|
||||||
|
m.last_hint = hint;
|
||||||
m.last_attempt = Some(ts);
|
m.last_attempt = Some(ts);
|
||||||
}
|
}
|
||||||
self.persist_locked();
|
self.persist_locked();
|
||||||
@@ -453,18 +645,34 @@ impl NfsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mount_options(m: &NfsMount) -> String {
|
/// Build the `-o` option list. With `minimal=true` we strip everything
|
||||||
|
/// except the protocol version and ro/rw — used on the retry path when
|
||||||
|
/// the first attempt failed at option transformation, which historically
|
||||||
|
/// indicates one of the auxiliary options confused `nfs_options2string()`.
|
||||||
|
fn mount_options(m: &NfsMount, minimal: bool) -> String {
|
||||||
let mut opts = vec![m.version.vers_arg().to_string()];
|
let mut opts = vec![m.version.vers_arg().to_string()];
|
||||||
if m.read_only {
|
if m.read_only {
|
||||||
opts.push("ro".into());
|
opts.push("ro".into());
|
||||||
} else {
|
} else {
|
||||||
opts.push("rw".into());
|
opts.push("rw".into());
|
||||||
}
|
}
|
||||||
|
if minimal {
|
||||||
|
return opts.join(",");
|
||||||
|
}
|
||||||
|
// Explicit TCP. NFSv4.x is TCP-only by spec, but stating it
|
||||||
|
// doesn't hurt and on NFSv3 it's necessary on appliances that
|
||||||
|
// don't bind UDP (which is most modern ones).
|
||||||
|
opts.push("proto=tcp".into());
|
||||||
// `nolock` for v3 — many storage appliances disable lockd; we don't
|
// `nolock` for v3 — many storage appliances disable lockd; we don't
|
||||||
// need locking for read-only ISO access anyway.
|
// need locking for read-only ISO access anyway. nfs-utils still
|
||||||
|
// tries to contact rpc.statd without it which is a no-op overhead.
|
||||||
if matches!(m.version, NfsVersion::V3) {
|
if matches!(m.version, NfsVersion::V3) {
|
||||||
opts.push("nolock".into());
|
opts.push("nolock".into());
|
||||||
}
|
}
|
||||||
|
// Non-standard port hint to the kernel.
|
||||||
|
if m.port != DEFAULT_NFS_PORT {
|
||||||
|
opts.push(format!("port={}", m.port));
|
||||||
|
}
|
||||||
// Soft mount with a generous timeout — better to surface a hung share
|
// 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
|
// as a user-visible error than to wedge the iPXE client forever on a
|
||||||
// dead NFS server.
|
// dead NFS server.
|
||||||
@@ -474,6 +682,158 @@ fn mount_options(m: &NfsMount) -> String {
|
|||||||
opts.join(",")
|
opts.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invoke `mount -t nfs`. Returns `(success, stderr_trimmed,
|
||||||
|
/// exit_code)`. `stderr` is captured separately from `stdout`;
|
||||||
|
/// `mount(8)` passes mount.nfs's stderr through verbatim, so we get the
|
||||||
|
/// same diagnostics ("mount.nfs: ...") whether we invoke `mount.nfs`
|
||||||
|
/// directly or go through the generic wrapper.
|
||||||
|
///
|
||||||
|
/// We deliberately stay on `mount` rather than `mount.nfs` directly
|
||||||
|
/// because `/bin/mount` is in every user's PATH; `mount.nfs` lives in
|
||||||
|
/// `/sbin` (or `/usr/sbin`) and is *not* in the default PATH for the
|
||||||
|
/// non-root `openpxe` user. The generic `mount` binary knows where its
|
||||||
|
/// NFS helper lives and dispatches accordingly.
|
||||||
|
async fn run_mount_nfs(
|
||||||
|
opts: &str,
|
||||||
|
target: &str,
|
||||||
|
local: &Path,
|
||||||
|
) -> std::io::Result<(bool, String, i32)> {
|
||||||
|
let output = Command::new("mount")
|
||||||
|
.arg("-t")
|
||||||
|
.arg("nfs")
|
||||||
|
.arg("-o")
|
||||||
|
.arg(opts)
|
||||||
|
.arg(target)
|
||||||
|
.arg(local)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
Ok((output.status.success(), stderr, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to open a TCP connection to `server:port` within `PROBE_TIMEOUT`.
|
||||||
|
/// On failure returns `(error_text, hint_text)` — pre-formatted so the
|
||||||
|
/// caller can persist both.
|
||||||
|
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
let addr = format!("{server}:{port}");
|
||||||
|
let connect = TcpStream::connect(&addr);
|
||||||
|
match tokio::time::timeout(PROBE_TIMEOUT, connect).await {
|
||||||
|
Ok(Ok(_stream)) => Ok(()),
|
||||||
|
Ok(Err(e)) => Err((
|
||||||
|
format!("cannot reach NFS port: {addr}: {e}"),
|
||||||
|
format!(
|
||||||
|
"verify the NFS service is running on {server} and that port {port} is open"
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Err(_) => Err((
|
||||||
|
format!("cannot reach NFS port: {addr}: timed out after {}s", PROBE_TIMEOUT.as_secs()),
|
||||||
|
format!(
|
||||||
|
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
|
||||||
|
PROBE_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect mount.nfs's "failed to apply fstab options" / "internal option
|
||||||
|
/// parsing error" path. These messages come from
|
||||||
|
/// `nfs_options2string()` / `nfs_validate_options()` in nfs-utils and
|
||||||
|
/// are emitted *before* the mount(2) syscall, so retrying with a
|
||||||
|
/// stripped option set often succeeds.
|
||||||
|
fn looks_like_option_transform_failure(stderr: &str) -> bool {
|
||||||
|
let s = stderr.to_ascii_lowercase();
|
||||||
|
s.contains("failed to apply fstab options")
|
||||||
|
|| s.contains("internal option parsing error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a mount.nfs stderr blob into an operator-friendly hint.
|
||||||
|
/// Returns `None` if we don't have a translation — the caller will fall
|
||||||
|
/// back to surfacing the raw stderr.
|
||||||
|
#[allow(clippy::if_same_then_else)] // ordering matters; keep the patterns explicit
|
||||||
|
fn hint_for(stderr: &str) -> Option<String> {
|
||||||
|
let s = stderr.to_ascii_lowercase();
|
||||||
|
if s.contains("failed to apply fstab options") || s.contains("internal option parsing error") {
|
||||||
|
// The dominant report from the field: mount.nfs failed at the
|
||||||
|
// option-transform layer. Most common root cause is missing
|
||||||
|
// CAP_SYS_ADMIN in the container.
|
||||||
|
Some(
|
||||||
|
"mount.nfs couldn't finalize the mount. Most common cause: the container is \
|
||||||
|
missing CAP_SYS_ADMIN (run with --cap-add=SYS_ADMIN, or use a privileged SCC on \
|
||||||
|
OpenShift). Also check that /etc/mtab exists and the host kernel has NFS client \
|
||||||
|
support."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("operation not permitted") || s.contains("permission denied") {
|
||||||
|
Some(
|
||||||
|
"the container is missing CAP_SYS_ADMIN — mount(2) returns EPERM without it. Re-run \
|
||||||
|
with --cap-add=SYS_ADMIN, or grant the OpenShift pod a privileged SCC."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("access denied by server") {
|
||||||
|
Some(
|
||||||
|
"the server rejected this client. Check the export's allowed-hosts list includes \
|
||||||
|
this OpenPXE host's IP (or 0.0.0.0/0 for testing)."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("no route to host") || s.contains("network is unreachable") {
|
||||||
|
Some("the server is not reachable on this network. Check the IP, subnet, and routes.".into())
|
||||||
|
} else if s.contains("connection refused") {
|
||||||
|
Some(
|
||||||
|
"the NFS service isn't listening on this address/port. Verify NFS is running and \
|
||||||
|
that the export path is correct (e.g. UniFi UNAS Pro exports under \
|
||||||
|
/var/nfs/shared/<name>, not the share name on its own)."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("connection timed out") {
|
||||||
|
Some(
|
||||||
|
"no answer from the server within the connect timeout. Most likely a firewall is \
|
||||||
|
dropping the connection, or the server isn't running NFS on this port."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("no such file or directory")
|
||||||
|
|| s.contains("mount: bad option")
|
||||||
|
|| s.contains("does not exist")
|
||||||
|
{
|
||||||
|
Some(
|
||||||
|
"the export path doesn't exist on the server, or a mount option isn't recognized. \
|
||||||
|
Double-check the export — many NAS appliances bury it under a service root like \
|
||||||
|
/var/nfs/shared/<share>."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("rpc: program not registered") || s.contains("mount system call failed") {
|
||||||
|
Some(
|
||||||
|
"the server didn't respond on the expected RPC programs. NFSv4.1 needs nfsd on TCP \
|
||||||
|
2049; NFSv3 also needs portmap (111) and mountd. If the server only speaks one \
|
||||||
|
version, switch the dropdown to match."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else if s.contains("protocol not supported") || s.contains("invalid argument") {
|
||||||
|
Some(
|
||||||
|
"the server doesn't speak the requested NFS version. Try the other entry in the \
|
||||||
|
Version dropdown."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a server input: trim, strip a `http(s)://` prefix that the
|
||||||
|
/// operator may have pasted by mistake, and drop a trailing slash. Port
|
||||||
|
/// suffixes (`host:1234`) are preserved so the kernel sees them; the
|
||||||
|
/// explicit `port=` option still wins if the operator set one.
|
||||||
|
fn normalize_server(raw: &str) -> String {
|
||||||
|
let s = raw.trim();
|
||||||
|
let s = s
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.or_else(|| s.strip_prefix("https://"))
|
||||||
|
.or_else(|| s.strip_prefix("nfs://"))
|
||||||
|
.unwrap_or(s);
|
||||||
|
s.trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn mount_id(server: &str, export: &str) -> String {
|
fn mount_id(server: &str, export: &str) -> String {
|
||||||
let raw = format!("{server}{export}");
|
let raw = format!("{server}{export}");
|
||||||
slugify_str(&raw)
|
slugify_str(&raw)
|
||||||
@@ -500,6 +860,23 @@ async fn is_mountpoint(path: &Path) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn make_mount(version: NfsVersion, ro: bool, port: u16) -> NfsMount {
|
||||||
|
NfsMount {
|
||||||
|
id: "x".into(),
|
||||||
|
server: "s".into(),
|
||||||
|
export: "/e".into(),
|
||||||
|
version,
|
||||||
|
read_only: ro,
|
||||||
|
port,
|
||||||
|
local_path: PathBuf::from("/tmp/x"),
|
||||||
|
mounted: false,
|
||||||
|
last_error: None,
|
||||||
|
last_hint: None,
|
||||||
|
last_attempt: None,
|
||||||
|
iso_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_arg() {
|
fn version_arg() {
|
||||||
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
|
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
|
||||||
@@ -507,44 +884,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mount_options_v3_includes_nolock() {
|
fn mount_options_v3_includes_nolock_and_tcp() {
|
||||||
let m = NfsMount {
|
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
|
||||||
id: "x".into(),
|
let opts = mount_options(&m, false);
|
||||||
server: "s".into(),
|
assert!(opts.contains("vers=3"), "got: {opts}");
|
||||||
export: "/e".into(),
|
assert!(opts.contains("ro"), "got: {opts}");
|
||||||
version: NfsVersion::V3,
|
assert!(opts.contains("nolock"), "got: {opts}");
|
||||||
read_only: true,
|
assert!(opts.contains("proto=tcp"), "got: {opts}");
|
||||||
local_path: PathBuf::from("/tmp/x"),
|
assert!(opts.contains("soft"), "got: {opts}");
|
||||||
mounted: false,
|
assert!(!opts.contains("port="), "default port shouldn't appear: {opts}");
|
||||||
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]
|
#[test]
|
||||||
fn mount_options_v41_no_nolock() {
|
fn mount_options_v41_has_tcp_no_nolock() {
|
||||||
let m = NfsMount {
|
let m = make_mount(NfsVersion::V41, false, DEFAULT_NFS_PORT);
|
||||||
id: "x".into(),
|
let opts = mount_options(&m, false);
|
||||||
server: "s".into(),
|
assert!(opts.contains("vers=4.1"), "got: {opts}");
|
||||||
export: "/e".into(),
|
assert!(opts.contains("rw"), "got: {opts}");
|
||||||
version: NfsVersion::V41,
|
assert!(opts.contains("proto=tcp"), "got: {opts}");
|
||||||
read_only: false,
|
assert!(!opts.contains("nolock"), "got: {opts}");
|
||||||
local_path: PathBuf::from("/tmp/x"),
|
}
|
||||||
mounted: false,
|
|
||||||
last_error: None,
|
#[test]
|
||||||
last_attempt: None,
|
fn mount_options_minimal_drops_everything_except_vers_and_mode() {
|
||||||
iso_count: 0,
|
let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
|
||||||
};
|
let opts = mount_options(&m, true);
|
||||||
let opts = mount_options(&m);
|
assert_eq!(opts, "vers=3,ro");
|
||||||
assert!(opts.contains("vers=4.1"));
|
}
|
||||||
assert!(opts.contains("rw"));
|
|
||||||
assert!(!opts.contains("nolock"));
|
#[test]
|
||||||
|
fn mount_options_non_default_port_appears() {
|
||||||
|
let m = make_mount(NfsVersion::V41, true, 2050);
|
||||||
|
let opts = mount_options(&m, false);
|
||||||
|
assert!(opts.contains("port=2050"), "got: {opts}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -555,4 +927,58 @@ mod tests {
|
|||||||
assert!(!a.contains('/'));
|
assert!(!a.contains('/'));
|
||||||
assert!(!a.contains('.'));
|
assert!(!a.contains('.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_server_strips_url_schemes_and_slashes() {
|
||||||
|
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
|
||||||
|
assert_eq!(normalize_server("http://10.0.0.5/"), "10.0.0.5");
|
||||||
|
assert_eq!(normalize_server("https://nas.lan//"), "nas.lan");
|
||||||
|
assert_eq!(normalize_server("nfs://192.168.1.51"), "192.168.1.51");
|
||||||
|
assert_eq!(normalize_server("nas.lan:2049"), "nas.lan:2049");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_for_fstab_options_calls_out_cap_sys_admin() {
|
||||||
|
let h = hint_for("mount.nfs: failed to apply fstab options").unwrap();
|
||||||
|
assert!(
|
||||||
|
h.contains("CAP_SYS_ADMIN"),
|
||||||
|
"expected CAP_SYS_ADMIN guidance, got: {h}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_for_access_denied_points_at_exports_table() {
|
||||||
|
let h = hint_for("mount.nfs: access denied by server while mounting").unwrap();
|
||||||
|
assert!(
|
||||||
|
h.to_lowercase().contains("allowed-hosts") || h.to_lowercase().contains("export"),
|
||||||
|
"expected exports hint, got: {h}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_for_connection_refused_mentions_export_path() {
|
||||||
|
let h = hint_for("mount.nfs: Connection refused").unwrap();
|
||||||
|
assert!(
|
||||||
|
h.to_lowercase().contains("export"),
|
||||||
|
"expected export-path hint, got: {h}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_for_unknown_message_is_none() {
|
||||||
|
assert!(hint_for("some completely unrelated text").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looks_like_option_transform_failure_detects_both_variants() {
|
||||||
|
assert!(looks_like_option_transform_failure(
|
||||||
|
"mount.nfs: failed to apply fstab options"
|
||||||
|
));
|
||||||
|
assert!(looks_like_option_transform_failure(
|
||||||
|
"mount.nfs: internal option parsing error"
|
||||||
|
));
|
||||||
|
assert!(!looks_like_option_transform_failure(
|
||||||
|
"mount.nfs: access denied"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Operator-logo compositor for the iPXE menu.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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`.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
|
||||||
|
/// 1024×768 PNG and return the encoded bytes.
|
||||||
|
///
|
||||||
|
/// Errors when the source can't be decoded or the encoded buffer can't
|
||||||
|
/// be written (only really fires on out-of-memory; the encoder itself
|
||||||
|
/// is infallible for well-formed inputs).
|
||||||
|
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||||
|
let logo = image::load_from_memory(src_bytes)?;
|
||||||
|
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
|
||||||
|
// keeps the antialiasing crisp on the framebuffer console; it's a
|
||||||
|
// touch slower than `Triangle` but the operator hits this endpoint
|
||||||
|
// once per boot at most.
|
||||||
|
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
|
||||||
|
let logo_rgba = logo.to_rgba8();
|
||||||
|
|
||||||
|
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
|
||||||
|
// on framebuffer consoles; older builds simply draw the alpha as
|
||||||
|
// black, which still gives a sensible look.
|
||||||
|
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
|
||||||
|
let logo_w = logo_rgba.width();
|
||||||
|
let logo_h = logo_rgba.height();
|
||||||
|
// Horizontal center, top-margin from the top. Saturating math
|
||||||
|
// means a logo wider than CANVAS_W (shouldn't happen after the
|
||||||
|
// downscale above, but defensive) just sits flush-left.
|
||||||
|
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
|
||||||
|
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
|
||||||
|
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(64 * 1024);
|
||||||
|
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
|
||||||
|
Ok(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::{ImageBuffer, Rgb};
|
||||||
|
|
||||||
|
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||||
|
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
|
||||||
|
let mut out = Vec::with_capacity(4096);
|
||||||
|
DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_emits_canvas_sized_png() {
|
||||||
|
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();
|
||||||
|
assert_eq!(img.width(), CANVAS_W);
|
||||||
|
assert_eq!(img.height(), CANVAS_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_logo_centered_at_top_margin() {
|
||||||
|
let src = solid_png(100, 40, [10, 200, 10]);
|
||||||
|
let out = compose_pxe_logo(&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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_bytes_returns_error_not_panic() {
|
||||||
|
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
|
||||||
|
assert!(r.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,18 @@
|
|||||||
--topbar-h: 56px;
|
--topbar-h: 56px;
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
--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"] {
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
/* Light palette — high-contrast neutral, accent unchanged for brand
|
/* Light palette — high-contrast neutral, accent unchanged for brand
|
||||||
consistency. Designed against Netbox Labs's reference screenshot:
|
consistency. Designed against Netbox Labs's reference screenshot:
|
||||||
near-white surfaces, soft grey dividers, dark text. */
|
near-white surfaces, soft grey dividers, dark text. */
|
||||||
@@ -304,6 +313,23 @@ label.field textarea {
|
|||||||
font-size: 14px; line-height: 1.4;
|
font-size: 14px; line-height: 1.4;
|
||||||
box-shadow: none; -webkit-appearance: none; appearance: none;
|
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 {
|
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||||
outline: none; border-color: var(--accent);
|
outline: none; border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
@@ -313,7 +339,40 @@ label.check {
|
|||||||
padding: 8px 10px; margin-bottom: 6px;
|
padding: 8px 10px; margin-bottom: 6px;
|
||||||
border: 1px solid var(--border-soft); border-radius: var(--radius);
|
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 ────────────────────────────────────────────────────── */
|
/* ── Drop zone ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
+61
-20
@@ -623,19 +623,41 @@
|
|||||||
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
|
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
|
||||||
const addNfs = el('button', {onclick: async () => {
|
const addNfs = el('button', {onclick: async () => {
|
||||||
if (!nfsServer.value || !nfsExport.value) {
|
if (!nfsServer.value || !nfsExport.value) {
|
||||||
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
|
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||||
|
nfsMsg.className='msg err'; return;
|
||||||
}
|
}
|
||||||
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
|
nfsMsg.replaceChildren(document.createTextNode('Mounting…'));
|
||||||
|
nfsMsg.className = 'msg';
|
||||||
const r = await postJSON('/api/nfs', {
|
const r = await postJSON('/api/nfs', {
|
||||||
server: nfsServer.value, export: nfsExport.value,
|
server: nfsServer.value, export: nfsExport.value,
|
||||||
version: nfsVer.value, read_only: nfsRo.checked,
|
version: nfsVer.value, read_only: nfsRo.checked,
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
|
nfsMsg.replaceChildren(document.createTextNode('Mounted.'));
|
||||||
|
nfsMsg.className = 'msg ok';
|
||||||
render('storage');
|
render('storage');
|
||||||
} else {
|
} else {
|
||||||
const t = await r.text();
|
// v0.4.64: the API now returns a structured
|
||||||
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
|
// {error, stderr, hint} JSON body so we can render the
|
||||||
|
// mount failure and an actionable hint as two distinct lines
|
||||||
|
// instead of one long unreadable string. The dominant field
|
||||||
|
// failure mode — "mount.nfs: failed to apply fstab options" —
|
||||||
|
// becomes useful when paired with its CAP_SYS_ADMIN hint.
|
||||||
|
let body = null;
|
||||||
|
let raw = null;
|
||||||
|
try { body = await r.clone().json(); }
|
||||||
|
catch (_) { raw = await r.text().catch(()=> 'mount failed'); }
|
||||||
|
const msg = body && body.error ? body.error : (raw || 'mount failed');
|
||||||
|
const hint = body && body.hint;
|
||||||
|
const parts = [el('div', {}, [
|
||||||
|
el('strong', {}, 'Mount 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';
|
||||||
}
|
}
|
||||||
}}, 'Mount share');
|
}}, 'Mount share');
|
||||||
|
|
||||||
@@ -648,6 +670,8 @@
|
|||||||
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
|
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
|
||||||
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
|
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
|
||||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||||
|
// v0.4.64: actionable hint paired with the raw error.
|
||||||
|
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||||
]),
|
]),
|
||||||
el('button', {class:'ghost', onclick: async () => {
|
el('button', {class:'ghost', onclick: async () => {
|
||||||
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
|
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
|
||||||
@@ -1025,7 +1049,11 @@
|
|||||||
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
|
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
|
||||||
placeholder: 'confirm new password'});
|
placeholder: 'confirm new password'});
|
||||||
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
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';
|
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
||||||
if (!currentPw.value) {
|
if (!currentPw.value) {
|
||||||
accountMsg.textContent = 'Current password is required.';
|
accountMsg.textContent = 'Current password is required.';
|
||||||
@@ -1130,29 +1158,39 @@
|
|||||||
el('option', {value:'xml'}, 'Metadata XML'),
|
el('option', {value:'xml'}, 'Metadata XML'),
|
||||||
]);
|
]);
|
||||||
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
|
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'}, [
|
const urlWrap = el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'IdP metadata URL'),
|
el('span', {class:'name'}, 'IdP metadata URL'),
|
||||||
ssoUrl,
|
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'),
|
el('span', {class:'name'}, 'IdP metadata XML'),
|
||||||
ssoXml,
|
ssoXml,
|
||||||
el('span', {class:'hint'},
|
el('span', {class:'hint'},
|
||||||
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
|
'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 = () => {
|
const refreshSsoFields = () => {
|
||||||
if (ssoMode.value === 'url') {
|
if (ssoMode.value === 'url') {
|
||||||
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
|
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
|
||||||
|
urlHint.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
|
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
|
||||||
|
urlHint.style.display = 'none';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ssoMode.onchange = refreshSsoFields;
|
ssoMode.onchange = refreshSsoFields;
|
||||||
refreshSsoFields();
|
|
||||||
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
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';
|
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: ssoEnabled.checked,
|
enabled: ssoEnabled.checked,
|
||||||
@@ -1193,32 +1231,35 @@
|
|||||||
ssoEnabled,
|
ssoEnabled,
|
||||||
el('span', {}, 'Enable single sign-on'),
|
el('span', {}, 'Enable single sign-on'),
|
||||||
]),
|
]),
|
||||||
// 3-column header strip: display name, logo URL, metadata
|
// v0.4.63: 4-column form-row that matches the Administrator
|
||||||
// source. All three controls inherit the same border/padding/
|
// account card above column-for-column — display name / logo
|
||||||
// focus chrome from the global `label.field input/select`
|
// URL / metadata source / metadata URL. All four controls share
|
||||||
// rule, so they line up cleanly. Below: the active source
|
// the same `label.field` chrome so they line up cleanly. When
|
||||||
// field (URL or XML) spans the full width.
|
// the operator picks "Metadata XML" the URL column collapses
|
||||||
el('div', {class:'form-row cols-3'}, [
|
// and the multi-line textarea drops below the row.
|
||||||
|
el('div', {class:'form-row'}, [
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'IdP display name'),
|
el('span', {class:'name'}, 'IdP display name'),
|
||||||
ssoName,
|
ssoName,
|
||||||
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
|
|
||||||
]),
|
]),
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'IdP logo URL'),
|
el('span', {class:'name'}, 'IdP logo URL'),
|
||||||
ssoLogo,
|
ssoLogo,
|
||||||
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
|
|
||||||
]),
|
]),
|
||||||
el('label', {class:'field'}, [
|
el('label', {class:'field'}, [
|
||||||
el('span', {class:'name'}, 'Metadata source'),
|
el('span', {class:'name'}, 'Metadata source'),
|
||||||
ssoMode,
|
ssoMode,
|
||||||
]),
|
]),
|
||||||
|
urlWrap,
|
||||||
]),
|
]),
|
||||||
urlWrap,
|
|
||||||
xmlWrap,
|
xmlWrap,
|
||||||
|
urlHint,
|
||||||
ssoSave, ssoMsg,
|
ssoSave, ssoMsg,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
// Wire up + paint the initial visibility now that all elements
|
||||||
|
// referenced by `refreshSsoFields` are attached.
|
||||||
|
refreshSsoFields();
|
||||||
|
|
||||||
// ── Custom logo upload.
|
// ── Custom logo upload.
|
||||||
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<title>OpenPXE</title>
|
<title>OpenPXE</title>
|
||||||
<link rel="stylesheet" href="/assets/app.css" />
|
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
request time with the running OpenPXE version. That guarantees a
|
||||||
|
fresh URL on every upgrade so browsers (and intermediary proxies)
|
||||||
|
can't keep serving stale JS / CSS / branding from before the
|
||||||
|
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
|
||||||
|
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}}" />
|
||||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
<!-- Theme is read from localStorage *before* paint to avoid the
|
||||||
dark→light flash on every navigation. Falls back to the OS
|
dark→light flash on every navigation. Falls back to the OS
|
||||||
preference and finally to dark. -->
|
preference and finally to dark. -->
|
||||||
@@ -26,7 +33,7 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/assets/logo.svg" alt="OpenPXE" />
|
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
|
||||||
<strong>OpenPXE</strong>
|
<strong>OpenPXE</strong>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -59,7 +66,7 @@
|
|||||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||||
identity is always visible regardless of branding. -->
|
identity is always visible regardless of branding. -->
|
||||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.6</span></div>
|
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.63</span></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -115,6 +122,6 @@
|
|||||||
<main class="main" id="view-root"></main>
|
<main class="main" id="view-root"></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/app.js"></script>
|
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-4
@@ -7,11 +7,21 @@
|
|||||||
//! nav, top bar with secondary tabs, card-dense content panels.
|
//! nav, top bar with secondary tabs, card-dense content panels.
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
/// Render the top-level page. `base_url` is interpolated into the footer
|
/// Render the top-level page.
|
||||||
/// so operators can see at a glance what URL clients are PXE-booting from.
|
///
|
||||||
|
/// * `base_url` is interpolated into the footer so operators can see at
|
||||||
|
/// a glance what URL clients are PXE-booting from.
|
||||||
|
/// * `asset_version` is appended as `?v=…` to every asset URL so each
|
||||||
|
/// release ships with brand-new asset URLs — browsers (and any
|
||||||
|
/// intermediary proxy) can't keep serving last release's `app.js`
|
||||||
|
/// 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.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn index_html(base_url: &str) -> String {
|
pub fn index_html(base_url: &str, asset_version: &str) -> String {
|
||||||
INDEX_HTML.replace("{{BASE_URL}}", base_url)
|
INDEX_HTML
|
||||||
|
.replace("{{BASE_URL}}", base_url)
|
||||||
|
.replace("{{ASSET_VERSION}}", asset_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|||||||
@@ -16,7 +16,15 @@
|
|||||||
|
|
||||||
ARG RUST_VERSION=1.95
|
ARG RUST_VERSION=1.95
|
||||||
|
|
||||||
########## fetch iPXE binaries ##########
|
########## 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.
|
||||||
FROM debian:12-slim AS fetch
|
FROM debian:12-slim AS fetch
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user