Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f694f7c79 | ||
|
|
d729a7ae2f | ||
|
|
1419309a2d | ||
|
|
55f4765a20 |
Generated
+187
-8
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -84,6 +90,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -212,12 +224,24 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -280,6 +304,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -295,6 +325,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -406,6 +445,25 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -549,6 +607,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
@@ -821,6 +889,34 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -958,6 +1054,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -969,6 +1075,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -1001,6 +1117,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openpxe"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1037,7 +1162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-core"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
@@ -1056,7 +1181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-dhcp-proxy"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1070,13 +1195,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-http-api"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
"futures",
|
||||
"hyper",
|
||||
"image",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"openpxe-core",
|
||||
@@ -1100,7 +1226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-ipxe-assets"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"openpxe-core",
|
||||
"rust-embed",
|
||||
@@ -1110,12 +1236,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-iso-store"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"bytes",
|
||||
"hex",
|
||||
"image",
|
||||
"libc",
|
||||
"openpxe-core",
|
||||
"parking_lot",
|
||||
@@ -1133,7 +1260,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-tftp"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1147,7 +1274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpxe-webui"
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
@@ -1184,6 +1311,19 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1227,6 +1367,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1484,6 +1636,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2124,6 +2282,12 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -2438,3 +2602,18 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]
|
||||
version = "0.4.5"
|
||||
version = "0.4.63"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -33,6 +33,11 @@ pub struct SsoConfig {
|
||||
/// with X" button label. Empty/whitespace falls back to "SSO".
|
||||
#[serde(default)]
|
||||
pub idp_name: String,
|
||||
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
|
||||
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
|
||||
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
|
||||
#[serde(default)]
|
||||
pub idp_logo_url: String,
|
||||
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
|
||||
/// with `metadata_url`; if both are set, the URL wins at apply time
|
||||
/// (operators typically forget about a stale XML paste).
|
||||
@@ -100,6 +105,7 @@ impl SsoStore {
|
||||
/// but the server enforces a hard ceiling regardless.
|
||||
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
|
||||
cfg.idp_name = cfg.idp_name.trim().to_string();
|
||||
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
|
||||
cfg.metadata = cfg.metadata.trim().to_string();
|
||||
cfg.metadata_url = cfg.metadata_url.trim().to_string();
|
||||
if cfg.metadata.len() > MAX_METADATA_BYTES {
|
||||
@@ -112,6 +118,11 @@ impl SsoStore {
|
||||
"metadata_url exceeds {MAX_URL_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
if cfg.idp_logo_url.len() > MAX_URL_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
if !cfg.metadata_url.is_empty()
|
||||
&& !cfg.metadata_url.starts_with("http://")
|
||||
&& !cfg.metadata_url.starts_with("https://")
|
||||
@@ -120,6 +131,14 @@ impl SsoStore {
|
||||
"metadata_url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
if !cfg.idp_logo_url.is_empty()
|
||||
&& !cfg.idp_logo_url.starts_with("http://")
|
||||
&& !cfg.idp_logo_url.starts_with("https://")
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"idp_logo_url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
// If they're trying to *enable* the integration but haven't
|
||||
// supplied either source, reject — saves a "configured but
|
||||
// unusable" surprise later.
|
||||
@@ -197,6 +216,7 @@ mod tests {
|
||||
idp_name: "Okta".into(),
|
||||
metadata: String::new(),
|
||||
metadata_url: "https://idp.example.com/metadata".into(),
|
||||
idp_logo_url: String::new(),
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
@@ -218,6 +238,7 @@ mod tests {
|
||||
idp_name: "Test IdP".into(),
|
||||
metadata: xml.into(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
@@ -232,6 +253,7 @@ mod tests {
|
||||
idp_name: "Okta".into(),
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
// …and a disabled blank config is fine.
|
||||
@@ -247,10 +269,38 @@ mod tests {
|
||||
idp_name: String::new(),
|
||||
metadata: String::new(),
|
||||
metadata_url: "ftp://idp.example.com/metadata".into(),
|
||||
idp_logo_url: String::new(),
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idp_logo_url_must_be_http_scheme() {
|
||||
// v0.4.6: SSO settings learned an idp_logo_url so the login
|
||||
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
|
||||
// affordance. Same scheme rule as metadata_url.
|
||||
let dir = tempdir().unwrap();
|
||||
let s = SsoStore::load_or_default(dir.path());
|
||||
let r = s.replace(SsoConfig {
|
||||
enabled: false,
|
||||
idp_name: "Okta".into(),
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: "data:image/png;base64,...".into(),
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
// Real HTTPS URL is fine.
|
||||
s.replace(SsoConfig {
|
||||
enabled: false,
|
||||
idp_name: "Okta".into(),
|
||||
metadata: String::new(),
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: "https://idp.example.com/logo.png".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_size_cap_enforced() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -261,6 +311,7 @@ mod tests {
|
||||
idp_name: String::new(),
|
||||
metadata: oversize,
|
||||
metadata_url: String::new(),
|
||||
idp_logo_url: String::new(),
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -41,3 +41,6 @@ tower = { workspace = true }
|
||||
tempfile = "3.12"
|
||||
serde_json = { 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"] }
|
||||
|
||||
+126
-10
@@ -51,6 +51,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/assets/app.css", get(ui_css))
|
||||
.route("/assets/logo.svg", get(ui_logo))
|
||||
.route("/assets/loader.svg", get(ui_loader))
|
||||
// v0.4.6: PXE menu logo — the raster form of the operator's
|
||||
// uploaded mark, served so iPXE's `console --picture` can
|
||||
// overlay it on the boot menu. SVG uploads 404 here (iPXE
|
||||
// can't rasterize SVG); we deliberately don't bundle a
|
||||
// pre-rendered PNG fallback because iPXE's ASCII wordmark
|
||||
// banner already provides the always-visible branding.
|
||||
.route("/branding/pxe-logo", get(ui_pxe_logo))
|
||||
// iPXE script endpoints.
|
||||
.route("/boot.ipxe", get(boot_top_menu))
|
||||
.route("/boot/:filename", get(boot_sub))
|
||||
@@ -186,23 +193,47 @@ async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>)
|
||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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"),
|
||||
)],
|
||||
),
|
||||
// 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,
|
||||
)
|
||||
.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 {
|
||||
(
|
||||
[(
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/javascript"),
|
||||
)],
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::app_js(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -210,7 +241,10 @@ async fn ui_js() -> 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(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -254,21 +288,101 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
|
||||
}
|
||||
}
|
||||
(
|
||||
[(
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::logo_svg(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
|
||||
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
|
||||
/// GIF) of any aspect ratio; this handler decodes it, draws it
|
||||
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
|
||||
/// That gives the same look as iVentoy regardless of what the operator
|
||||
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
|
||||
/// land in the same place on the boot screen.
|
||||
///
|
||||
/// 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 {
|
||||
let Some(path) = state.branding.logo_path() else {
|
||||
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
|
||||
};
|
||||
let Some(mime) = state.branding.logo_mime() else {
|
||||
return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
|
||||
};
|
||||
if mime == "image/svg+xml" {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("custom logo unreadable: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
|
||||
// centered-top regardless of the operator's source dimensions. The
|
||||
// `image` crate is pure-Rust + sync; offload to a blocking task
|
||||
// because Lanczos resampling on a 4K source can take tens of
|
||||
// milliseconds and we don't want to block the executor.
|
||||
let composed =
|
||||
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(png)) => png,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::branding",
|
||||
error = %e, "failed to compose PXE logo PNG"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to compose PXE logo: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pxe-logo task failed: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
composed,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_loader() -> Response {
|
||||
(
|
||||
[(
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
),
|
||||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||||
],
|
||||
openpxe_webui::loader_svg(),
|
||||
)
|
||||
.into_response()
|
||||
@@ -945,6 +1059,8 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
||||
{"method": "DELETE", "path": "/api/branding/logo",
|
||||
"summary": "Remove the custom logo and revert to the bundled mark."},
|
||||
{"method": "GET", "path": "/branding/pxe-logo",
|
||||
"summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
|
||||
{"method": "GET", "path": "/api/sso",
|
||||
"summary": "Current SAML SSO configuration."},
|
||||
{"method": "PUT", "path": "/api/sso",
|
||||
|
||||
@@ -440,6 +440,9 @@ mod tests {
|
||||
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
|
||||
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
|
||||
"/metrics",
|
||||
// v0.4.6: iPXE fetches this for `console --picture` before
|
||||
// it can possibly have a session cookie.
|
||||
"/branding/pxe-logo",
|
||||
] {
|
||||
assert!(is_public_path(p), "expected {p} to be public");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ use std::fmt::Write as _;
|
||||
|
||||
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
|
||||
/// clients because iPXE normalises the menu primitives across firmwares.
|
||||
///
|
||||
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
|
||||
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
|
||||
/// paint it), a footer carrying version + arch + firmware kind, and an
|
||||
/// optional `console --picture` directive that paints the operator's
|
||||
/// uploaded raster logo on top when the iPXE binary on the wire was
|
||||
/// built with PNG support. The ASCII banner is always rendered so
|
||||
/// even when the picture call no-ops the screen still reads as
|
||||
/// "OpenPXE — here is the menu" rather than a featureless box.
|
||||
#[must_use]
|
||||
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -47,8 +56,41 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
let _ = writeln!(s, "set esc:hex 1b");
|
||||
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
|
||||
// v0.4.6: best-effort graphics console with the operator-uploaded
|
||||
// raster logo. Falls back to plain text console on iPXE builds
|
||||
// without PNG support — the `||` chain keeps a parse-clean
|
||||
// single-statement form so even the strictest iPXE parsers accept
|
||||
// it. The `console` reset at the end re-syncs the menu output.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"console --picture {base}/branding/pxe-logo || console"
|
||||
);
|
||||
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
||||
// iPXE evaluates `iseq` lazily, so we only set whichever line
|
||||
// matches. Anything not on the allowlist falls through to a generic
|
||||
// `<buildarch> <platform>` display.
|
||||
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
|
||||
);
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
||||
// ASCII OpenPXE wordmark. Works on every iPXE build, including
|
||||
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
|
||||
// so `console --picture` paints nothing). When the queued iPXE
|
||||
// source-build lands and the operator's uploaded raster actually
|
||||
// paints via `console --picture`, this banner can be retired in
|
||||
// favour of the real image. The compositor at
|
||||
// /branding/pxe-logo is already wired and waiting.
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
|
||||
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
|
||||
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
|
||||
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
|
||||
let _ = writeln!(s, "item --gap -- |_|");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"item --gap -- ------------------------- Default -------------------------"
|
||||
@@ -82,6 +124,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
||||
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key x exit Exit iPXE");
|
||||
// v0.4.6 footer line. Sits just above the `choose` line so it's
|
||||
// always visible regardless of how the menu paginates. iPXE
|
||||
// interpolates `${arch-label}` (set near the top of this script)
|
||||
// and `${version}` is the binary-baked iPXE version — *not* the
|
||||
// OpenPXE version — so we hard-code the OpenPXE version string
|
||||
// here.
|
||||
let openpxe_version = env!("CARGO_PKG_VERSION");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
|
||||
);
|
||||
|
||||
if matches!(settings.timeout_action, TimeoutAction::Stay) {
|
||||
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
|
||||
@@ -575,6 +629,53 @@ mod password_tests {
|
||||
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_menu_has_polished_branding_and_arch_footer() {
|
||||
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
|
||||
// `console --picture` line that PNG-capable iPXE builds will
|
||||
// honour (queued for a follow-up release once we can rebuild
|
||||
// iPXE from source on native x86_64 hardware), an ASCII
|
||||
// OpenPXE wordmark that works on every iPXE build (including
|
||||
// the boot.ipxe.org pre-builds we currently ship), and a
|
||||
// single-line footer carrying the OpenPXE version + arch.
|
||||
let settings = Settings::default();
|
||||
let s = render_menu(&[], &settings, "http://10.0.0.5");
|
||||
assert!(
|
||||
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
|
||||
"missing console --picture line:\n{s}"
|
||||
);
|
||||
// Picture-or-text-console must be a single statement so older
|
||||
// iPXE parsers don't choke on the chain.
|
||||
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
|
||||
// ASCII wordmark — paints on every iPXE build regardless of
|
||||
// PNG support.
|
||||
assert!(
|
||||
s.contains("___ ___ __ __ ___"),
|
||||
"ASCII banner missing first row:\n{s}"
|
||||
);
|
||||
// Footer with version + arch interpolation. The version comes
|
||||
// from CARGO_PKG_VERSION at compile time.
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert!(
|
||||
s.contains(&format!("OpenPXE v{version}")),
|
||||
"footer missing OpenPXE version:\n{s}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("${arch-label}"),
|
||||
"footer missing arch-label interpolation:\n{s}"
|
||||
);
|
||||
// No website URL — the design brief calls that out as tacky.
|
||||
assert!(
|
||||
!s.to_ascii_lowercase().contains("openpxe.com"),
|
||||
"footer should not advertise the website:\n{s}"
|
||||
);
|
||||
// Arch-label mapping covers the three labels from the brief:
|
||||
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
|
||||
assert!(s.contains("x86 BIOS"), "{s}");
|
||||
assert!(s.contains("x86_64 UEFI"), "{s}");
|
||||
assert!(s.contains("arm64 UEFI"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
||||
let settings = Settings::default();
|
||||
|
||||
@@ -1742,3 +1742,119 @@ async fn docs_lists_new_v0_4_5_endpoints() {
|
||||
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pxe_logo_404_when_no_custom_logo_configured() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("no custom logo"), "got: {text}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
|
||||
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
|
||||
// light up the PXE menu's `console --picture` overlay — the menu
|
||||
// simply paints without a logo.
|
||||
let (state, _dir) = build_state().await;
|
||||
state
|
||||
.branding
|
||||
.set_logo(
|
||||
"image/svg+xml",
|
||||
"svg",
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("SVG"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 png = tiny_png();
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", &png)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/branding/pxe-logo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(ct, "image/png");
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
// PNG signature.
|
||||
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]
|
||||
async fn pxe_logo_endpoint_is_public_after_admin_setup() {
|
||||
// iPXE clients can't send a session cookie, so /branding/pxe-logo
|
||||
// must stay reachable once the admin has been bootstrapped. The
|
||||
// auth allowlist gates `/api/*` only.
|
||||
let (state, _dir) = build_state().await;
|
||||
let png = tiny_png();
|
||||
state
|
||||
.branding
|
||||
.set_logo("image/png", "png", &png)
|
||||
.unwrap();
|
||||
let app = build_router(state);
|
||||
// Configure an admin so the middleware kicks in.
|
||||
let (s, _, _) = post_collect(
|
||||
&app,
|
||||
"/api/setup",
|
||||
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CREATED);
|
||||
// Still public without a cookie.
|
||||
let (s, _) = get(&app, "/branding/pxe-logo").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ parking_lot.workspace = true
|
||||
bytes.workspace = true
|
||||
tempfile = "3.12"
|
||||
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]
|
||||
tempfile = "3.12"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs;
|
||||
pub mod pxe_logo;
|
||||
pub mod smb;
|
||||
pub mod store;
|
||||
pub mod windows;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+132
-17
@@ -34,9 +34,18 @@
|
||||
--topbar-h: 56px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
|
||||
date pickers) to the active OpenPXE theme. Without this, the inline
|
||||
`<meta name="color-scheme" content="dark light">` in index.html forces
|
||||
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
|
||||
checkbox renders as an opaque black square against the light-mode
|
||||
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
|
||||
the meta and tracks `data-theme` correctly. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
/* Light palette — high-contrast neutral, accent unchanged for brand
|
||||
consistency. Designed against Netbox Labs's reference screenshot:
|
||||
near-white surfaces, soft grey dividers, dark text. */
|
||||
@@ -287,23 +296,83 @@ label.field {
|
||||
}
|
||||
label.field .name { color: var(--fg-dim); font-size: 12px; }
|
||||
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
|
||||
label.field input[type="text"],
|
||||
label.field input[type="number"],
|
||||
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
|
||||
styled type=text/number, which left type=password fields rendering
|
||||
with the default browser look — visibly off vs adjacent text fields
|
||||
in the Account card. The negation list keeps `type=checkbox`,
|
||||
`type=file`, and `type=range` (none of which we use inside
|
||||
`label.field`) from picking up the padded-box look. */
|
||||
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
|
||||
label.field select,
|
||||
label.field textarea {
|
||||
width: 100%; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 7px 10px; font: inherit;
|
||||
/* iOS/Safari shrinks password-field text by default; clamp it so
|
||||
the password input matches the username input's metrics. */
|
||||
font-size: 14px; line-height: 1.4;
|
||||
box-shadow: none; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
|
||||
disappears, which makes the "Metadata source" pick-list look like a
|
||||
plain (and slightly squished) text input. Paint our own chevron via
|
||||
background-image so the control still reads as a dropdown, and reserve
|
||||
right-padding for it. The data-URI SVG inherits currentColor via the
|
||||
`stroke` attribute so the arrow follows light/dark theme without a
|
||||
second declaration. */
|
||||
label.field select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 11px 7px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
:root[data-theme="light"] label.field select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
|
||||
}
|
||||
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
label.check {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius);
|
||||
}
|
||||
label.check input { accent-color: var(--accent); }
|
||||
/* v0.4.63: native checkboxes used to render as opaque black squares in
|
||||
light mode because the page meta declares `color-scheme: dark light`
|
||||
and `accent-color` alone only repaints the *check mark* (not the
|
||||
container). Take full control of the chrome so the box reads cleanly
|
||||
on both palettes and the checked state lights up in our accent. */
|
||||
label.check input[type="checkbox"] {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
width: 16px; height: 16px; flex: none;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
display: inline-grid; place-content: center;
|
||||
cursor: pointer; margin: 0;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
|
||||
label.check input[type="checkbox"]:checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
label.check input[type="checkbox"]:checked::after {
|
||||
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
|
||||
#002923 (the same near-black we use on solid-accent buttons) so the
|
||||
tick stays legible against the teal fill in both themes. */
|
||||
content: '';
|
||||
width: 4px; height: 8px;
|
||||
border: solid #002923;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
label.check input[type="checkbox"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ── Drop zone ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -421,9 +490,21 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.dot.err { background: var(--err); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
|
||||
/* Inline form rows. */
|
||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
||||
/* Inline form rows. The default is a 4-column grid sized for the
|
||||
Account card's "Current / New username / New password / Confirm"
|
||||
quartet; the `.cols-3` modifier swaps to a 3-column layout for the
|
||||
SSO header strip (display name / logo URL / metadata source). All
|
||||
`.form-row > label.field` children share the same baseline because
|
||||
their inner inputs share metrics via the global rule above. */
|
||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
|
||||
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-row label.field { margin-bottom: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.form-row,
|
||||
.form-row.cols-3,
|
||||
.form-row.cols-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Queued deployment visual ────────────────────────────────────── */
|
||||
.queue-track {
|
||||
@@ -567,22 +648,56 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
}
|
||||
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
|
||||
|
||||
/* ── Logout chip (sidebar footer) ────────────────────────────── */
|
||||
.sidebar .footer .logout-row {
|
||||
margin-top: 8px; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px;
|
||||
/* ── Top-right user menu (v0.4.6) ────────────────────────────
|
||||
The "signed in as X" identity + sign-out moved out of the sidebar
|
||||
footer in v0.4.6 — the sidebar footer is now reserved for the
|
||||
service-state trio (Service status / Advertised URL / Backend
|
||||
version). The button matches the theme toggle's size + chrome so
|
||||
the top-right reads as a tidy two-icon strip. */
|
||||
.user-menu { position: relative; }
|
||||
.user-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 32px;
|
||||
background: transparent; color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: pointer; padding: 0;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.sidebar .footer .logout-row .who {
|
||||
color: var(--fg); font-weight: 600; font-size: 11.5px;
|
||||
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
|
||||
.user-pop {
|
||||
position: absolute; right: 0; top: 38px;
|
||||
min-width: 200px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 6px;
|
||||
z-index: 60;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.user-pop[hidden] { display: none; }
|
||||
.user-pop .user-pop-name {
|
||||
padding: 8px 10px 6px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
margin-bottom: 4px;
|
||||
color: var(--fg); font-weight: 600; font-size: 13px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sidebar .footer .logout-btn {
|
||||
background: transparent; color: var(--fg-dim);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500;
|
||||
.user-pop .user-pop-item {
|
||||
text-align: left; width: 100%;
|
||||
background: transparent; color: var(--fg);
|
||||
border: 0; border-radius: var(--radius);
|
||||
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar .footer .logout-btn:hover { color: var(--fg); background: var(--bg-panel-2); border-color: var(--accent); }
|
||||
.user-pop .user-pop-item:hover {
|
||||
background: var(--bg-panel-2); color: var(--fg);
|
||||
}
|
||||
.user-pop .user-pop-danger { color: var(--err); }
|
||||
.user-pop .user-pop-danger:hover {
|
||||
background: color-mix(in srgb, var(--err) 12%, transparent);
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
/* ── About card ─────────────────────────────────────────────────── */
|
||||
.about-hero { padding: 20px 24px; }
|
||||
|
||||
+108
-25
@@ -1025,7 +1025,11 @@
|
||||
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
|
||||
placeholder: 'confirm new password'});
|
||||
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||||
const accountSave = el('button', {onclick: async () => {
|
||||
// v0.4.63: explicit top margin so the action button sits clearly
|
||||
// beneath the input row instead of butting against the password
|
||||
// fields. Mirrors the `Save SSO settings` button below for visual
|
||||
// parity between the two settings cards.
|
||||
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
|
||||
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
||||
if (!currentPw.value) {
|
||||
accountMsg.textContent = 'Current password is required.';
|
||||
@@ -1103,47 +1107,71 @@
|
||||
ssoEnabled.checked = !!sso.enabled;
|
||||
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
|
||||
value: sso.idp_name || ''});
|
||||
// v0.4.6: optional FleetDM-style IdP logo URL. The login screen
|
||||
// will render this as the brand mark on the "Sign in with X"
|
||||
// button once the runtime SSO flow ships; for v0.4.6 we just
|
||||
// persist it.
|
||||
const ssoLogo = el('input', {type:'text',
|
||||
placeholder:'https://idp.example.com/logo.svg',
|
||||
value: sso.idp_logo_url || ''});
|
||||
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
|
||||
value: sso.metadata_url || ''});
|
||||
const ssoXml = el('textarea', {rows:'6',
|
||||
// The textarea inherits the same chrome via the global
|
||||
// `label.field textarea` rule, plus the monospace family for
|
||||
// pasting raw XML. Children come after the attrs object — the
|
||||
// initial value is the only "child".
|
||||
const ssoXml = el('textarea',
|
||||
{rows:'6',
|
||||
spellcheck:'false', autocapitalize:'off',
|
||||
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
|
||||
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
|
||||
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
|
||||
'padding:8px 10px;resize:vertical'},
|
||||
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
|
||||
sso.metadata || '');
|
||||
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
|
||||
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
|
||||
// The mode picker is a styled <select> so it aligns with text
|
||||
// inputs in the same `.form-row` — the global `label.field
|
||||
// select` rule takes care of the chrome.
|
||||
const ssoMode = el('select', {}, [
|
||||
el('option', {value:'url'}, 'Metadata URL'),
|
||||
el('option', {value:'xml'}, 'Metadata XML'),
|
||||
]);
|
||||
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
|
||||
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
|
||||
// grid as column 4, so the SSO row is column-for-column aligned with
|
||||
// the Administrator account row above. When the operator switches
|
||||
// to XML mode, column 4 collapses (display:none) and the multi-line
|
||||
// XML textarea takes its own full-width row below — there's no way
|
||||
// to fit a 6-row textarea into a single grid cell without making
|
||||
// the rest of the row look stretched.
|
||||
const urlWrap = el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP metadata URL'),
|
||||
ssoUrl,
|
||||
el('span', {class:'hint'},
|
||||
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.5 just stores it.'),
|
||||
]);
|
||||
const xmlWrap = el('label', {class:'field'}, [
|
||||
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
|
||||
el('span', {class:'name'}, 'IdP metadata XML'),
|
||||
ssoXml,
|
||||
el('span', {class:'hint'},
|
||||
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
|
||||
]);
|
||||
// Hint that used to live under the URL field; surfaced once below
|
||||
// the whole row so it doesn't compete with the in-grid layout.
|
||||
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
|
||||
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
|
||||
const refreshSsoFields = () => {
|
||||
if (ssoMode.value === 'url') {
|
||||
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
|
||||
urlHint.style.display = '';
|
||||
} else {
|
||||
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
|
||||
urlHint.style.display = 'none';
|
||||
}
|
||||
};
|
||||
ssoMode.onchange = refreshSsoFields;
|
||||
refreshSsoFields();
|
||||
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||||
const ssoSave = el('button', {onclick: async () => {
|
||||
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
||||
const payload = {
|
||||
enabled: ssoEnabled.checked,
|
||||
idp_name: ssoName.value,
|
||||
idp_logo_url: ssoLogo.value,
|
||||
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
|
||||
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
|
||||
};
|
||||
@@ -1179,22 +1207,35 @@
|
||||
ssoEnabled,
|
||||
el('span', {}, 'Enable single sign-on'),
|
||||
]),
|
||||
// v0.4.63: 4-column form-row that matches the Administrator
|
||||
// account card above column-for-column — display name / logo
|
||||
// URL / metadata source / metadata URL. All four controls share
|
||||
// the same `label.field` chrome so they line up cleanly. When
|
||||
// the operator picks "Metadata XML" the URL column collapses
|
||||
// and the multi-line textarea drops below the row.
|
||||
el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP display name'),
|
||||
ssoName,
|
||||
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'IdP logo URL'),
|
||||
ssoLogo,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Metadata source'),
|
||||
ssoMode,
|
||||
]),
|
||||
]),
|
||||
urlWrap,
|
||||
]),
|
||||
xmlWrap,
|
||||
urlHint,
|
||||
ssoSave, ssoMsg,
|
||||
]),
|
||||
]);
|
||||
// Wire up + paint the initial visibility now that all elements
|
||||
// referenced by `refreshSsoFields` are attached.
|
||||
refreshSsoFields();
|
||||
|
||||
// ── Custom logo upload.
|
||||
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
||||
@@ -1610,25 +1651,67 @@
|
||||
}
|
||||
|
||||
async function startDashboard() {
|
||||
// Light up the sidebar's "signed in as X / Sign out" row. It was
|
||||
// hidden in index.html because we don't know the identity until
|
||||
// /api/me resolves.
|
||||
// v0.4.6: light up the top-right user-menu chip. The button is
|
||||
// hidden in index.html until /api/me confirms a signed-in session,
|
||||
// so we don't show the icon (then hide it) when the user lands
|
||||
// on /login. Clicking the icon opens a small popover with
|
||||
// Name / Edit account / Sign out.
|
||||
try {
|
||||
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
|
||||
const row = $('[data-bind=logout_row]');
|
||||
const who = $('[data-bind=signed_in_as]');
|
||||
const btn = $('[data-bind=logout_btn]');
|
||||
if (row && me && me.authenticated && me.user) {
|
||||
who.textContent = me.user.username;
|
||||
who.title = 'Signed in as ' + me.user.username;
|
||||
row.style.display = '';
|
||||
const wrap = $('[data-bind=user_menu_wrap]');
|
||||
const pop = $('[data-bind=user_menu_pop]');
|
||||
const name = $('[data-bind=user_pop_name]');
|
||||
const edit = $('[data-bind=user_pop_edit]');
|
||||
const out = $('[data-bind=user_pop_logout]');
|
||||
const btn = $('#user-menu-btn');
|
||||
if (wrap && me && me.authenticated && me.user) {
|
||||
wrap.style.display = '';
|
||||
if (name) name.textContent = me.user.username;
|
||||
if (btn) btn.title = 'Signed in as ' + me.user.username;
|
||||
if (btn && !btn._wired) {
|
||||
btn._wired = true;
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const open = !pop.hidden;
|
||||
pop.hidden = open;
|
||||
btn.setAttribute('aria-expanded', String(!open));
|
||||
});
|
||||
}
|
||||
if (edit && !edit._wired) {
|
||||
edit._wired = true;
|
||||
edit.addEventListener('click', () => {
|
||||
pop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
render('settings');
|
||||
});
|
||||
}
|
||||
if (out && !out._wired) {
|
||||
out._wired = true;
|
||||
out.addEventListener('click', async () => {
|
||||
pop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
await fetch('/api/logout', {method:'POST'}).catch(() => {});
|
||||
wrap.style.display = 'none';
|
||||
showAuthScreen('login');
|
||||
});
|
||||
}
|
||||
// Click-outside-to-close, wired once. Stored on document so we
|
||||
// don't re-attach every render.
|
||||
if (!document._userPopWired) {
|
||||
document._userPopWired = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (pop.hidden) return;
|
||||
if (e.target.closest('.user-menu')) return;
|
||||
pop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !pop.hidden) {
|
||||
pop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { /* surfaces elsewhere */ }
|
||||
render('dashboard');
|
||||
|
||||
+33
-13
@@ -5,8 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>OpenPXE</title>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
||||
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
|
||||
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
|
||||
dark→light flash on every navigation. Falls back to the OS
|
||||
preference and finally to dark. -->
|
||||
@@ -26,7 +33,7 @@
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg" alt="OpenPXE" />
|
||||
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
|
||||
<strong>OpenPXE</strong>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -59,15 +66,7 @@
|
||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||
identity is always visible regardless of branding. -->
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.5</span></div>
|
||||
<!-- v0.4.5: signed-in identity + one-click sign-out. The button
|
||||
is populated by app.js after /api/me reports an authenticated
|
||||
session — pre-auth states swap the whole shell for the
|
||||
login/setup card so this row never gets shown there. -->
|
||||
<div class="logout-row" data-bind="logout_row" style="display:none">
|
||||
<span class="who" data-bind="signed_in_as" title=""></span>
|
||||
<button type="button" class="logout-btn" data-bind="logout_btn">Sign out</button>
|
||||
</div>
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.63</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -97,11 +96,32 @@
|
||||
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
|
||||
in the top-right corner so the sidebar footer stays clean for
|
||||
the "Service status / Advertised URL / Backend version" trio.
|
||||
The whole block is hidden until /api/me confirms a session. -->
|
||||
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
|
||||
<button id="user-menu-btn" class="user-btn" type="button"
|
||||
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
|
||||
title="Account">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="3.6"/>
|
||||
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
|
||||
<div class="user-pop-name" data-bind="user_pop_name">—</div>
|
||||
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
|
||||
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main" id="view-root"></main>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-4
@@ -7,11 +7,21 @@
|
||||
//! nav, top bar with secondary tabs, card-dense content panels.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Render the top-level page. `base_url` is interpolated into the footer
|
||||
/// so operators can see at a glance what URL clients are PXE-booting from.
|
||||
/// Render the top-level page.
|
||||
///
|
||||
/// * `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]
|
||||
pub fn index_html(base_url: &str) -> String {
|
||||
INDEX_HTML.replace("{{BASE_URL}}", base_url)
|
||||
pub fn index_html(base_url: &str, asset_version: &str) -> String {
|
||||
INDEX_HTML
|
||||
.replace("{{BASE_URL}}", base_url)
|
||||
.replace("{{ASSET_VERSION}}", asset_version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
|
||||
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
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& 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