v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
Closes the v0.4.x chapter — NFS works end to end. Five additions: ## Wake-on-LAN (Hosts → Bound hosts) - New core::wol module: parse any MAC form, build the 102-byte magic packet, broadcast it. No special capability needed (ephemeral source port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255) AND the server's own subnet broadcast (computed from advertised IP + detected mask) so it reaches the right VLAN. - POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise) so it's not an open packet sprayer. - Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓ state. ## Webhook notifications (Advanced tab) - core::notify: NotifyConfig + NotifyStore (notify.json), one provider at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP. SMTP password is persisted but redacted on GET behind a __keep__ sentinel the UI round-trips so the secret never leaves the box. - http-api::notify: delivery — reqwest POST for chat (provider-shaped bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext). 10s timeout; every send is best-effort. - GET/PUT /api/notify, POST /api/notify/test. - Fired fire-and-forget on the canonical "machine is imaging" boot event and on WoL — never blocks the boot path. ## UI: Advanced tab - New nav item. Holds the webhook config card and the API reference block (relocated from the bottom of Settings). ## UI: login/setup logo (FleetDM treatment) - /api/me now returns has_custom_logo + logo_rev (public bootstrap). The login, setup, and connection-error cards render the uploaded logo full-width with the "OpenPXE" wordmark dropped — matching the sidebar. ## About: update check + licenses - "Check for updates" button → GET /api/updates/check queries the Gitea releases API (derived from CARGO_PKG_REPOSITORY) and compares to the running version. Strictly on-demand — no background polling, keeps the air-gapped promise. - License card documents the MIT OR Apache-2.0 dual license with links, plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools). Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both rustls so the static musl binary stays OpenSSL-free. Tests: 179 passing (+notify round-trip/redaction, webhook validation, WoL-unbound-404, WoL packet loopback, version-compare). clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb41288c3
commit
fc99973ac3
@@ -12,9 +12,11 @@ pub mod error;
|
||||
pub mod host_bindings;
|
||||
pub mod log_bus;
|
||||
pub mod metrics;
|
||||
pub mod notify;
|
||||
pub mod queue;
|
||||
pub mod settings;
|
||||
pub mod sso;
|
||||
pub mod wol;
|
||||
|
||||
pub use arch::{ClientArch, FirmwareClass};
|
||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||
@@ -27,5 +29,6 @@ pub use error::{Error, Result};
|
||||
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||
pub use metrics::{HttpRoute, Metrics};
|
||||
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
|
||||
pub use queue::{DeploymentQueue, QueueEntry};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Webhook / email notification configuration.
|
||||
//!
|
||||
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
|
||||
//! something noteworthy happens (a machine PXE-booted an image, a
|
||||
//! deployment was assigned, a WoL was sent). One active provider at a
|
||||
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
|
||||
//! paste the incoming-webhook URL, done.
|
||||
//!
|
||||
//! This module owns only the *configuration* (validation + persistence
|
||||
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
|
||||
//! chat providers, SMTP for email — lives in the http-api crate, which
|
||||
//! already carries an HTTP client and the SMTP dependency. Keeping the
|
||||
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
|
||||
//! stay pure config stores.
|
||||
//!
|
||||
//! Secrets note: the SMTP password is persisted in `notify.json`
|
||||
//! alongside the rest of the config (0644 like the other state files).
|
||||
//! It is never echoed back through the API — the snapshot used for the
|
||||
//! GET response blanks it (see `Self::redacted`).
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_URL_LEN: usize = 2048;
|
||||
const MAX_FIELD_LEN: usize = 512;
|
||||
|
||||
/// Which notification transport is active.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotifyKind {
|
||||
/// Slack incoming webhook (`{ "text": ... }`).
|
||||
#[default]
|
||||
Slack,
|
||||
/// Discord webhook (`{ "content": ... }`).
|
||||
Discord,
|
||||
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
|
||||
Teams,
|
||||
/// Email via SMTP.
|
||||
Smtp,
|
||||
}
|
||||
|
||||
impl NotifyKind {
|
||||
/// True when this kind drives a chat webhook (POST a JSON body to a
|
||||
/// single URL) rather than SMTP.
|
||||
#[must_use]
|
||||
pub fn is_webhook(self) -> bool {
|
||||
matches!(self, Self::Slack | Self::Discord | Self::Teams)
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-configurable notification settings. Single provider active
|
||||
/// at a time; the inactive fields are kept so switching providers
|
||||
/// doesn't wipe the other one's values.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NotifyConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub kind: NotifyKind,
|
||||
/// Incoming-webhook URL for Slack / Discord / Teams.
|
||||
#[serde(default)]
|
||||
pub webhook_url: String,
|
||||
|
||||
// ── SMTP fields (used when kind == Smtp) ──
|
||||
#[serde(default)]
|
||||
pub smtp_host: String,
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub smtp_port: u16,
|
||||
#[serde(default)]
|
||||
pub smtp_username: String,
|
||||
#[serde(default)]
|
||||
pub smtp_password: String,
|
||||
/// `From:` address. Falls back to `smtp_username` when blank.
|
||||
#[serde(default)]
|
||||
pub smtp_from: String,
|
||||
/// `To:` address (single recipient — keep it simple).
|
||||
#[serde(default)]
|
||||
pub smtp_to: String,
|
||||
/// Use implicit TLS (port 465). When false we use STARTTLS on the
|
||||
/// configured port (587 typical). Either way the connection is
|
||||
/// encrypted — we never offer plaintext SMTP.
|
||||
#[serde(default)]
|
||||
pub smtp_implicit_tls: bool,
|
||||
}
|
||||
|
||||
fn default_smtp_port() -> u16 {
|
||||
587
|
||||
}
|
||||
|
||||
impl NotifyConfig {
|
||||
/// True when enabled and the active provider has the fields it
|
||||
/// needs to actually send.
|
||||
#[must_use]
|
||||
pub fn is_usable(&self) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
if self.kind.is_webhook() {
|
||||
!self.webhook_url.trim().is_empty()
|
||||
} else {
|
||||
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy safe to return over the API: the SMTP password is blanked
|
||||
/// (replaced with a non-empty sentinel only when one is set, so the
|
||||
/// UI can show "configured" without leaking it).
|
||||
#[must_use]
|
||||
pub fn redacted(&self) -> NotifyConfig {
|
||||
let mut c = self.clone();
|
||||
if !c.smtp_password.is_empty() {
|
||||
c.smtp_password = SECRET_SENTINEL.to_string();
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by the API in place of a stored password. When the UI PUTs
|
||||
/// this value back unchanged we keep the existing password rather than
|
||||
/// overwriting it with the sentinel.
|
||||
pub const SECRET_SENTINEL: &str = "__keep__";
|
||||
|
||||
/// In-memory + on-disk notification config registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<NotifyConfig>>,
|
||||
}
|
||||
|
||||
impl NotifyStore {
|
||||
#[must_use]
|
||||
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("notify.json");
|
||||
let cfg = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "openpxe::notify",
|
||||
"notify.json unreadable ({e}); starting with defaults"
|
||||
);
|
||||
NotifyConfig::default()
|
||||
}),
|
||||
Err(_) => NotifyConfig::default(),
|
||||
};
|
||||
Self {
|
||||
path: Arc::new(path),
|
||||
inner: Arc::new(RwLock::new(cfg)),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> NotifyConfig {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
|
||||
/// is treated as "keep the existing password" so the UI never has to
|
||||
/// round-trip the real secret.
|
||||
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
|
||||
incoming.webhook_url = incoming.webhook_url.trim().to_string();
|
||||
incoming.smtp_host = incoming.smtp_host.trim().to_string();
|
||||
incoming.smtp_username = incoming.smtp_username.trim().to_string();
|
||||
incoming.smtp_from = incoming.smtp_from.trim().to_string();
|
||||
incoming.smtp_to = incoming.smtp_to.trim().to_string();
|
||||
|
||||
// Preserve the stored password when the UI sends the sentinel.
|
||||
if incoming.smtp_password == SECRET_SENTINEL {
|
||||
incoming
|
||||
.smtp_password
|
||||
.clone_from(&self.inner.read().smtp_password);
|
||||
}
|
||||
|
||||
// Length caps.
|
||||
if incoming.webhook_url.len() > MAX_URL_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"webhook URL exceeds {MAX_URL_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
for (name, v) in [
|
||||
("smtp_host", &incoming.smtp_host),
|
||||
("smtp_username", &incoming.smtp_username),
|
||||
("smtp_from", &incoming.smtp_from),
|
||||
("smtp_to", &incoming.smtp_to),
|
||||
] {
|
||||
if v.len() > MAX_FIELD_LEN {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{name} exceeds {MAX_FIELD_LEN}-char cap"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the active provider only when enabling.
|
||||
if incoming.enabled {
|
||||
if incoming.kind.is_webhook() {
|
||||
if incoming.webhook_url.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"a webhook URL is required to enable chat notifications".into(),
|
||||
));
|
||||
}
|
||||
if !incoming.webhook_url.starts_with("https://")
|
||||
&& !incoming.webhook_url.starts_with("http://")
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"webhook URL must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if incoming.smtp_host.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"SMTP host is required to enable email notifications".into(),
|
||||
));
|
||||
}
|
||||
if incoming.smtp_to.is_empty() {
|
||||
return Err(Error::Invalid(
|
||||
"a recipient (To) is required to enable email notifications".into(),
|
||||
));
|
||||
}
|
||||
if incoming.smtp_port == 0 {
|
||||
return Err(Error::Invalid("SMTP port must be non-zero".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut g = self.inner.write();
|
||||
*g = incoming.clone();
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
target: "openpxe::notify",
|
||||
enabled = incoming.enabled, kind = ?incoming.kind,
|
||||
"notification configuration updated"
|
||||
);
|
||||
Ok(incoming)
|
||||
}
|
||||
|
||||
fn persist(&self) {
|
||||
let snap = self.inner.read().clone();
|
||||
let body = match serde_json::to_vec_pretty(&snap) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
||||
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn default_disabled_not_usable() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
assert!(!s.snapshot().enabled);
|
||||
assert!(!s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_requires_url_when_enabled() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Slack,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Slack,
|
||||
webhook_url: "https://hooks.slack.com/services/XXX".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_requires_host_and_recipient() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_from: "[email protected]".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.snapshot().is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_sentinel_preserves_stored_secret() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_password: "s3cret".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
// Redacted snapshot hides the password behind the sentinel.
|
||||
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
|
||||
// PUTting the sentinel back keeps the real password.
|
||||
s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Smtp,
|
||||
smtp_host: "smtp.example.com".into(),
|
||||
smtp_port: 587,
|
||||
smtp_to: "[email protected]".into(),
|
||||
smtp_password: SECRET_SENTINEL.into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.snapshot().smtp_password, "s3cret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_scheme_enforced() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = NotifyStore::load_or_default(dir.path());
|
||||
let r = s.replace(NotifyConfig {
|
||||
enabled: true,
|
||||
kind: NotifyKind::Discord,
|
||||
webhook_url: "ftp://example.com/hook".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(r, Err(Error::Invalid(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Wake-on-LAN.
|
||||
//!
|
||||
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
|
||||
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
|
||||
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
|
||||
//! local segment. The NIC's WoL logic matches the repeated MAC and
|
||||
//! powers the board on.
|
||||
//!
|
||||
//! ## Why this is trivial and safe in our container
|
||||
//!
|
||||
//! - It's a single UDP datagram to a broadcast address. No privileged
|
||||
//! *local* port is needed (we bind an ephemeral source port); the
|
||||
//! destination port is conventionally 9 (discard) or 7 (echo), and
|
||||
//! nothing actually listens there — the magic is in the payload, not
|
||||
//! the port. So WoL works without any extra capability.
|
||||
//! - We send to the limited broadcast `255.255.255.255` (stays on the
|
||||
//! local link) and, when the caller knows the server's own subnet
|
||||
//! broadcast, to that too — directed broadcast reaches the right VLAN
|
||||
//! even when the host bridges multiple segments.
|
||||
//!
|
||||
//! ## Limits
|
||||
//!
|
||||
//! WoL only crosses L2. If the target is on a different subnet than the
|
||||
//! OpenPXE host, the intervening router must be configured to forward
|
||||
//! directed broadcasts (most aren't, by design). For the common case —
|
||||
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
|
||||
//! is enough.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
|
||||
|
||||
/// Conventional WoL destination port. 9 (discard) is the de-facto
|
||||
/// default; the port is immaterial since the match is on the payload.
|
||||
const WOL_PORT: u16 = 9;
|
||||
|
||||
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
|
||||
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
|
||||
///
|
||||
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
|
||||
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
|
||||
// Strip every non-hex-digit, then expect exactly 12 hex chars.
|
||||
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
if hex.len() != 12 {
|
||||
return Err(Error::Invalid(format!(
|
||||
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
|
||||
hex.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; 6];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
// Each octet is two hex chars; unwrap is safe — we validated
|
||||
// the length and that every char is a hex digit above.
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the 102-byte magic packet for `mac`.
|
||||
#[must_use]
|
||||
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
|
||||
let mut pkt = [0u8; 102];
|
||||
// 6 bytes of 0xFF.
|
||||
for b in &mut pkt[..6] {
|
||||
*b = 0xFF;
|
||||
}
|
||||
// MAC repeated 16 times.
|
||||
for rep in 0..16 {
|
||||
let start = 6 + rep * 6;
|
||||
pkt[start..start + 6].copy_from_slice(&mac);
|
||||
}
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Send a Wake-on-LAN magic packet for `mac` to every address in
|
||||
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
|
||||
/// broadcast). Returns the number of broadcast addresses the packet was
|
||||
/// successfully sent to; errors only if the MAC is malformed or the
|
||||
/// socket can't be opened at all.
|
||||
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
|
||||
let parsed = parse_mac(mac)?;
|
||||
let packet = magic_packet(parsed);
|
||||
|
||||
// Always include the limited broadcast even if the caller didn't —
|
||||
// it's the one that works with zero network configuration.
|
||||
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||
for b in broadcasts {
|
||||
if !targets.contains(b) {
|
||||
targets.push(*b);
|
||||
}
|
||||
}
|
||||
|
||||
let sent = send_magic(&packet, &targets, WOL_PORT)?;
|
||||
tracing::info!(
|
||||
target: "openpxe::wol",
|
||||
mac = %mac, broadcasts = sent,
|
||||
"Wake-on-LAN magic packet sent"
|
||||
);
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// Open a broadcast-enabled UDP socket and send `packet` to every
|
||||
/// `target:port`. Returns how many sends succeeded. Errors if the
|
||||
/// socket can't be opened or if *no* target accepted the packet.
|
||||
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
|
||||
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
|
||||
// must be enabled to send to a broadcast address.
|
||||
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
|
||||
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
|
||||
sock.set_broadcast(true)
|
||||
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
|
||||
|
||||
let mut sent = 0usize;
|
||||
for &addr in targets {
|
||||
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
|
||||
Ok(_) => sent += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "openpxe::wol",
|
||||
broadcast = %addr,
|
||||
"WoL send failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if sent == 0 {
|
||||
return Err(Error::Invalid(
|
||||
"Wake-on-LAN: no broadcast address accepted the packet".into(),
|
||||
));
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
|
||||
/// Used so the caller can include the server's own subnet broadcast
|
||||
/// alongside the limited broadcast.
|
||||
#[must_use]
|
||||
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
|
||||
let ip = u32::from(ip);
|
||||
let mask = u32::from(mask);
|
||||
Ipv4Addr::from(ip | !mask)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_mac_accepts_common_forms() {
|
||||
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
||||
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
|
||||
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
|
||||
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
|
||||
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mac_rejects_bad_length() {
|
||||
assert!(parse_mac("aa:bb:cc").is_err());
|
||||
assert!(parse_mac("").is_err());
|
||||
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_packet_shape() {
|
||||
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||
assert_eq!(&pkt[..6], &[0xFF; 6]);
|
||||
// First MAC repetition.
|
||||
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||
// Last (16th) repetition ends the packet.
|
||||
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnet_broadcast_computes() {
|
||||
assert_eq!(
|
||||
subnet_broadcast(
|
||||
Ipv4Addr::new(192, 168, 1, 49),
|
||||
Ipv4Addr::new(255, 255, 255, 0)
|
||||
),
|
||||
Ipv4Addr::new(192, 168, 1, 255)
|
||||
);
|
||||
assert_eq!(
|
||||
subnet_broadcast(
|
||||
Ipv4Addr::new(10, 5, 3, 7),
|
||||
Ipv4Addr::new(255, 255, 0, 0)
|
||||
),
|
||||
Ipv4Addr::new(10, 5, 255, 255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_magic_delivers_intact_packet_over_loopback() {
|
||||
// Deterministic round-trip that doesn't depend on the sandbox
|
||||
// permitting a real L2 broadcast: bind a receiver on loopback
|
||||
// and confirm send_magic transmits the exact 102-byte packet.
|
||||
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = rx.local_addr().unwrap().port();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||
|
||||
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
||||
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
||||
assert_eq!(sent, 1);
|
||||
|
||||
let mut buf = [0u8; 128];
|
||||
let n = rx.recv(&mut buf).unwrap();
|
||||
assert_eq!(n, 102, "magic packet should be 102 bytes");
|
||||
assert_eq!(&buf[..102], &packet[..]);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ mime_guess.workspace = true
|
||||
uuid.workspace = true
|
||||
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
||||
parking_lot.workspace = true
|
||||
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
|
||||
# About-tab "check for updates" call to the Gitea releases API; SMTP for
|
||||
# email notifications. Both use rustls so the static musl binary stays
|
||||
# OpenSSL-free.
|
||||
reqwest.workspace = true
|
||||
lettre.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||
|
||||
+275
-3
@@ -31,8 +31,8 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use openpxe_core::{
|
||||
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
|
||||
MAX_LOGO_BYTES,
|
||||
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
|
||||
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
|
||||
@@ -159,9 +159,19 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
||||
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
|
||||
.route("/api/hosts/:mac", delete(api_hosts_remove))
|
||||
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
|
||||
// limited broadcast + the server's own subnet broadcast.
|
||||
.route("/api/hosts/:mac/wol", post(api_hosts_wol))
|
||||
// Rolling "host log" of boot events: what image actually
|
||||
// started installing on what MAC/IP, and when. Persisted to disk.
|
||||
.route("/api/boot-log", get(api_boot_log))
|
||||
// v0.5.0: notification config (Advanced tab) + a "send test"
|
||||
// probe. GET redacts the SMTP password.
|
||||
.route("/api/notify", get(api_notify_get).put(api_notify_put))
|
||||
.route("/api/notify/test", post(api_notify_test))
|
||||
// v0.5.0: About-tab update check — queries the Gitea releases
|
||||
// API and compares against the running version.
|
||||
.route("/api/updates/check", get(api_updates_check))
|
||||
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
||||
// format. No auth — the metrics surface is intentionally
|
||||
// boring (counts, no payloads).
|
||||
@@ -613,11 +623,22 @@ async fn boot_sub(
|
||||
.filter(|m| !m.is_empty());
|
||||
state.boot_log.record(&BootEvent {
|
||||
timestamp: time::OffsetDateTime::now_utc(),
|
||||
mac: mac_normalized,
|
||||
mac: mac_normalized.clone(),
|
||||
ip: peer_ip,
|
||||
target_id: entry.id.clone(),
|
||||
target_title: format!("{} — {}", iso.filename, entry.title),
|
||||
});
|
||||
// v0.5.0: fire-and-forget notification on the
|
||||
// canonical "a machine is imaging" moment.
|
||||
let who = mac_normalized
|
||||
.clone()
|
||||
.or_else(|| peer_ip.map(|ip| ip.to_string()))
|
||||
.unwrap_or_else(|| "an unknown client".into());
|
||||
spawn_notify(
|
||||
&state,
|
||||
"PXE boot started",
|
||||
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
|
||||
);
|
||||
return text_plain(render_entry(entry, &settings, base));
|
||||
}
|
||||
}
|
||||
@@ -1247,10 +1268,25 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
|
||||
{"method": "DELETE", "path": "/api/hosts/:mac",
|
||||
"summary": "Remove a binding."},
|
||||
{"method": "POST", "path": "/api/hosts/:mac/wol",
|
||||
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
|
||||
{"method": "GET", "path": "/api/boot-log",
|
||||
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Notifications & updates",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/notify",
|
||||
"summary": "Current notification config (SMTP password redacted)."},
|
||||
{"method": "PUT", "path": "/api/notify",
|
||||
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
|
||||
{"method": "POST", "path": "/api/notify/test",
|
||||
"summary": "Send a test notification using the saved config."},
|
||||
{"method": "GET", "path": "/api/updates/check",
|
||||
"summary": "Compare the running version against the latest Gitea release."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Operator console",
|
||||
"endpoints": [
|
||||
@@ -2022,6 +2058,220 @@ async fn api_hosts_remove(
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
|
||||
/// broadcast (255.255.255.255) and the server's own subnet broadcast
|
||||
/// (computed from the advertised IP + detected mask), which covers the
|
||||
/// common "same VLAN as OpenPXE" case with zero network config. We only
|
||||
/// wake MACs that are actually bound — keeps this from being an open
|
||||
/// "spray packets at any MAC" endpoint.
|
||||
async fn api_hosts_wol(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(mac): AxumPath<String>,
|
||||
) -> Response {
|
||||
if state.hosts.lookup(&mac).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"no host binding for that MAC — bind it first",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Compute the server's subnet broadcast from the advertised IP +
|
||||
// detected mask so the packet reaches the right VLAN even if the
|
||||
// limited broadcast is filtered. Best-effort: skip if either won't
|
||||
// parse.
|
||||
let server_ip = state
|
||||
.public_base_url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(&state.public_base_url)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse::<std::net::Ipv4Addr>();
|
||||
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
|
||||
let mut broadcasts = Vec::new();
|
||||
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
|
||||
broadcasts.push(wol::subnet_broadcast(ip, m));
|
||||
}
|
||||
|
||||
// The send is a blocking std UDP call; push it off the async
|
||||
// executor.
|
||||
let mac_owned = mac.clone();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||||
match result {
|
||||
Ok(Ok(n)) => {
|
||||
// Fire-and-forget notification — nice "someone woke a box"
|
||||
// signal, never blocks the response.
|
||||
spawn_notify(
|
||||
&state,
|
||||
"Wake-on-LAN sent",
|
||||
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
|
||||
);
|
||||
Json(json!({ "ok": true, "broadcasts": n })).into_response()
|
||||
}
|
||||
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
|
||||
|
||||
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
|
||||
// Redact the SMTP password before it leaves the process.
|
||||
Json(state.notify.snapshot().redacted())
|
||||
}
|
||||
|
||||
async fn api_notify_put(
|
||||
State(state): State<AppState>,
|
||||
Json(cfg): Json<NotifyConfig>,
|
||||
) -> Response {
|
||||
match state.notify.replace(cfg) {
|
||||
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a test notification using the *currently saved* config (not the
|
||||
/// request body) so the operator validates exactly what's persisted.
|
||||
async fn api_notify_test(State(state): State<AppState>) -> Response {
|
||||
let cfg = state.notify.snapshot();
|
||||
match crate::notify::send(
|
||||
&cfg,
|
||||
"OpenPXE test notification",
|
||||
"If you're reading this, OpenPXE notifications are wired up correctly. \
|
||||
This is a test from the Advanced settings tab.",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Json(json!({ "ok": true })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
|
||||
|
||||
/// Query the project's Gitea releases API for the latest published tag
|
||||
/// and compare it to the running version. This is the only outbound
|
||||
/// call OpenPXE makes that isn't operator-initiated data movement, and
|
||||
/// it's strictly on-demand (the About tab's "Check for updates" button)
|
||||
/// — never a background poll, keeping the air-gapped promise intact.
|
||||
async fn api_updates_check() -> Response {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
let Some(api) = gitea_releases_api_url() else {
|
||||
return Json(json!({
|
||||
"current": current,
|
||||
"error": "repository URL not configured at build time",
|
||||
}))
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Json(json!({ "current": current, "error": format!("client: {e}") }))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&api).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
|
||||
let latest_tag = body
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let html_url = body
|
||||
.get("html_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let update_available = version_is_newer(
|
||||
latest_tag.trim_start_matches('v'),
|
||||
current,
|
||||
);
|
||||
Json(json!({
|
||||
"current": current,
|
||||
"latest": latest_tag,
|
||||
"update_available": update_available,
|
||||
"html_url": html_url,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(resp) => Json(json!({
|
||||
"current": current,
|
||||
"error": format!("releases API returned HTTP {}", resp.status()),
|
||||
}))
|
||||
.into_response(),
|
||||
Err(e) => Json(json!({
|
||||
"current": current,
|
||||
"error": format!("could not reach the releases API: {e}"),
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the Gitea `releases/latest` API URL from the compile-time
|
||||
/// repository URL (`https://host/owner/repo`).
|
||||
fn gitea_releases_api_url() -> Option<String> {
|
||||
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
|
||||
let rest = repo
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| repo.strip_prefix("http://"))?;
|
||||
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
|
||||
let host = parts.next()?;
|
||||
let owner = parts.next()?;
|
||||
let name = parts.next()?;
|
||||
if host.is_empty() || owner.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
|
||||
))
|
||||
}
|
||||
|
||||
/// Compare two dotted numeric versions; `true` when `latest` is strictly
|
||||
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
|
||||
/// a garbage tag never falsely reports an update.
|
||||
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||
fn parts(v: &str) -> Vec<u64> {
|
||||
v.split('.')
|
||||
.map(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>())
|
||||
.map(|s| s.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
let (l, c) = (parts(latest), parts(current));
|
||||
for i in 0..l.len().max(c.len()) {
|
||||
let lv = l.get(i).copied().unwrap_or(0);
|
||||
let cv = c.get(i).copied().unwrap_or(0);
|
||||
if lv != cv {
|
||||
return lv > cv;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Fire a notification on a detached task. Never blocks the caller and
|
||||
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
|
||||
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
|
||||
let cfg = state.notify.snapshot();
|
||||
if !cfg.is_usable() {
|
||||
return;
|
||||
}
|
||||
let subject = subject.to_string();
|
||||
let body = body.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
|
||||
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Boot event log ───────────────────────────────────────────────────────
|
||||
|
||||
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
@@ -2081,6 +2331,28 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_newer_detects_updates() {
|
||||
assert!(version_is_newer("0.5.1", "0.5.0"));
|
||||
assert!(version_is_newer("0.6.0", "0.5.9"));
|
||||
assert!(version_is_newer("1.0.0", "0.9.9"));
|
||||
assert!(!version_is_newer("0.5.0", "0.5.0"));
|
||||
assert!(!version_is_newer("0.4.69", "0.5.0"));
|
||||
// A garbage / empty tag must never falsely report an update.
|
||||
assert!(!version_is_newer("", "0.5.0"));
|
||||
assert!(!version_is_newer("not-a-version", "0.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitea_api_url_derives_from_repo() {
|
||||
// CARGO_PKG_REPOSITORY is set from the workspace manifest.
|
||||
let url = gitea_releases_api_url();
|
||||
if let Some(u) = url {
|
||||
assert!(u.contains("/api/v1/repos/"), "got: {u}");
|
||||
assert!(u.ends_with("/releases/latest"), "got: {u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_full() {
|
||||
let (s, e, p) = parse_range(None, 1000).unwrap();
|
||||
|
||||
@@ -303,12 +303,20 @@ pub async fn api_logout(
|
||||
/// * `authenticated: false` — admin exists, no session; show login.
|
||||
/// * `authenticated: true` + `user` — let the dashboard load.
|
||||
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
// v0.5.0: include branding bootstrap so the pre-auth login/setup
|
||||
// screens can render the FleetDM-style full-width custom logo (and
|
||||
// cache-bust it) without an extra round trip. `/api/me` is public,
|
||||
// and the logo asset is public, so this leaks nothing sensitive.
|
||||
let has_custom_logo = state.branding.has_logo();
|
||||
let logo_rev = state.branding.logo_rev();
|
||||
if !state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"setup_required": true,
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
@@ -323,6 +331,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
"authenticated": true,
|
||||
"user": state.admin.snapshot(),
|
||||
"session_user": u,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -331,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
||||
Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": false,
|
||||
"has_custom_logo": has_custom_logo,
|
||||
"logo_rev": logo_rev,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod auth;
|
||||
pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
pub mod notify;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
pub mod uploads;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Notification *delivery* — the network half of the notify feature.
|
||||
//!
|
||||
//! `openpxe_core::notify` owns the config + persistence; this module
|
||||
//! turns a `NotifyConfig` + a message into an actual delivery:
|
||||
//!
|
||||
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
|
||||
//! body to the operator's incoming-webhook URL (via `reqwest`).
|
||||
//! - SMTP → a TLS email via `lettre`.
|
||||
//!
|
||||
//! Every send is best-effort and time-bounded: a flaky webhook must
|
||||
//! never wedge a PXE boot. Callers fire these from a detached task.
|
||||
|
||||
use openpxe_core::{NotifyConfig, NotifyKind};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
|
||||
/// task forever.
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Deliver `body` (with an optional `subject`, used as the email
|
||||
/// subject / chat bold-line) using the active provider in `cfg`.
|
||||
/// Returns `Ok(())` on success, or a human-readable error suitable for
|
||||
/// surfacing in the "Send test" response.
|
||||
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||
if !cfg.is_usable() {
|
||||
return Err("notifications are not enabled / fully configured".into());
|
||||
}
|
||||
match cfg.kind {
|
||||
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
|
||||
send_webhook(cfg, subject, body).await
|
||||
}
|
||||
NotifyKind::Smtp => send_email(cfg, subject, body).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||
// Each chat platform wants a different JSON shape for an incoming
|
||||
// webhook. Keep the bodies minimal and plain-text-ish so they
|
||||
// render cleanly everywhere.
|
||||
let combined = if subject.is_empty() {
|
||||
body.to_string()
|
||||
} else {
|
||||
format!("*{subject}*\n{body}")
|
||||
};
|
||||
let payload = match cfg.kind {
|
||||
NotifyKind::Slack => serde_json::json!({ "text": combined }),
|
||||
NotifyKind::Discord => serde_json::json!({ "content": combined }),
|
||||
NotifyKind::Teams => serde_json::json!({
|
||||
// Legacy MessageCard — the format every Teams "Incoming
|
||||
// Webhook" connector still accepts.
|
||||
"@type": "MessageCard",
|
||||
"@context": "https://schema.org/extensions",
|
||||
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
|
||||
"title": subject,
|
||||
"text": body,
|
||||
}),
|
||||
NotifyKind::Smtp => unreachable!("smtp handled separately"),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(SEND_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("could not build HTTP client: {e}"))?;
|
||||
let resp = client
|
||||
.post(&cfg.webhook_url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("webhook POST failed: {e}"))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let snippet = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
Err(format!("webhook returned HTTP {status}: {snippet}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
let from = if cfg.smtp_from.trim().is_empty() {
|
||||
cfg.smtp_username.trim()
|
||||
} else {
|
||||
cfg.smtp_from.trim()
|
||||
};
|
||||
if from.is_empty() {
|
||||
return Err("SMTP requires a From address (or a username to fall back to)".into());
|
||||
}
|
||||
|
||||
let email = Message::builder()
|
||||
.from(
|
||||
from.parse()
|
||||
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
|
||||
)
|
||||
.to(cfg
|
||||
.smtp_to
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
|
||||
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
|
||||
.body(body.to_string())
|
||||
.map_err(|e| format!("could not build email: {e}"))?;
|
||||
|
||||
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
|
||||
let mut builder = if cfg.smtp_implicit_tls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
|
||||
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
|
||||
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
|
||||
}
|
||||
.port(cfg.smtp_port)
|
||||
.timeout(Some(SEND_TIMEOUT));
|
||||
|
||||
// Auth is optional — some internal relays accept unauthenticated
|
||||
// mail from trusted hosts. Only attach credentials when a username
|
||||
// is set.
|
||||
if !cfg.smtp_username.trim().is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
cfg.smtp_username.trim().to_string(),
|
||||
cfg.smtp_password.clone(),
|
||||
));
|
||||
}
|
||||
let mailer = builder.build();
|
||||
mailer
|
||||
.send(email)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("SMTP send failed: {e}"))
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::uploads::UploadSessions;
|
||||
use crate::auth::SessionStore;
|
||||
use openpxe_core::{
|
||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
||||
Metrics, SettingsStore, SsoStore,
|
||||
Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||
};
|
||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
|
||||
use std::sync::Arc;
|
||||
@@ -37,6 +37,10 @@ pub struct AppState {
|
||||
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
|
||||
/// flow ships in a later release.
|
||||
pub sso: SsoStore,
|
||||
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
|
||||
/// SMTP). Drives the fire-and-forget pings on boot events and powers
|
||||
/// the Advanced tab's config + "Send test" button.
|
||||
pub notify: NotifyStore,
|
||||
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
||||
/// text format. Cheap to clone (handles to atomics).
|
||||
pub metrics: Metrics,
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
|
||||
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
|
||||
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
|
||||
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
|
||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||
let metrics = Metrics::new();
|
||||
let state = AppState {
|
||||
@@ -115,6 +116,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
admin,
|
||||
sessions,
|
||||
sso,
|
||||
notify,
|
||||
metrics,
|
||||
smb: None,
|
||||
smb_shares,
|
||||
@@ -535,6 +537,54 @@ async fn smb_shares_list_starts_empty() {
|
||||
|
||||
// v0.4.67: NFSv3 share manager (parallel to SMB).
|
||||
|
||||
// ── v0.5.0: notifications + Wake-on-LAN ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_config_round_trips_and_redacts_smtp_password() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
// Save an SMTP config with a password.
|
||||
let (s, _b) = put_json(
|
||||
&app,
|
||||
"/api/notify",
|
||||
r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"[email protected]","smtp_from":"[email protected]","smtp_password":"s3cret"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
// GET must redact the password (never echo the real secret).
|
||||
let (s, b) = get(&app, "/api/notify").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["enabled"], true);
|
||||
assert_eq!(v["kind"], "smtp");
|
||||
let pw = v["smtp_password"].as_str().unwrap_or("");
|
||||
assert_ne!(pw, "s3cret", "raw password must never be returned");
|
||||
assert!(!pw.is_empty(), "a set password should surface as a sentinel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_enable_webhook_without_url_is_rejected() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, _b) = put_json(
|
||||
&app,
|
||||
"/api/notify",
|
||||
r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wol_on_unbound_mac_is_404() {
|
||||
// WoL only fires for bound MACs — an arbitrary MAC must 404 so the
|
||||
// endpoint isn't an open packet sprayer.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_shares_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
|
||||
@@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
|
||||
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
|
||||
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
|
||||
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
|
||||
let sessions = openpxe_http_api::auth::SessionStore::default();
|
||||
let metrics = Metrics::new();
|
||||
|
||||
@@ -167,6 +168,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
admin: admin.clone(),
|
||||
sessions: sessions.clone(),
|
||||
sso: sso.clone(),
|
||||
notify: notify.clone(),
|
||||
metrics: metrics.clone(),
|
||||
smb: Some(smb.clone()),
|
||||
smb_shares: smb_shares.clone(),
|
||||
|
||||
@@ -627,6 +627,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
}
|
||||
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
|
||||
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
|
||||
/* v0.5.0: FleetDM-style custom logo on the login/setup card — the
|
||||
uploaded logo spans the card header and the "OpenPXE" wordmark is
|
||||
dropped (the logo is the brand). Matches the sidebar treatment. */
|
||||
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
|
||||
.auth-card .brand-row.has-custom-logo img {
|
||||
width: auto; height: 52px; max-width: 240px;
|
||||
object-fit: contain; object-position: center;
|
||||
}
|
||||
.auth-card h2 {
|
||||
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
|
||||
}
|
||||
|
||||
+260
-32
@@ -973,18 +973,37 @@
|
||||
}
|
||||
}}, 'Bind MAC to target');
|
||||
|
||||
const rows = hosts.map(h => el('tr', {}, [
|
||||
el('td', {class:'mono'}, h.mac),
|
||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||||
el('td', {class:'mono'}, h.target),
|
||||
el('td', {}, fmtAgo(h.updated_at)),
|
||||
el('td', {style:'text-align:right'},
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||||
render('hosts');
|
||||
}}, 'Remove')),
|
||||
]));
|
||||
const rows = hosts.map(h => {
|
||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||
// table is bound hosts). The button reports its own state inline
|
||||
// so there's no shared toast to thread through.
|
||||
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
|
||||
wakeBtn.disabled = true;
|
||||
const original = wakeBtn.textContent;
|
||||
wakeBtn.textContent = 'Waking…';
|
||||
try {
|
||||
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
|
||||
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
|
||||
} catch (e) {
|
||||
wakeBtn.textContent = 'Failed';
|
||||
}
|
||||
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
|
||||
}}, 'Wake');
|
||||
return el('tr', {}, [
|
||||
el('td', {class:'mono'}, h.mac),
|
||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||||
el('td', {class:'mono'}, h.target),
|
||||
el('td', {}, fmtAgo(h.updated_at)),
|
||||
el('td', {style:'text-align:right;white-space:nowrap'}, [
|
||||
wakeBtn,
|
||||
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
|
||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||||
render('hosts');
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
const table = hosts.length
|
||||
? el('table', {}, [
|
||||
@@ -1185,9 +1204,8 @@
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, docs, me, sso] = await Promise.all([
|
||||
const [status, me, sso] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
getJSON('/api/me').catch(() => ({})),
|
||||
getJSON('/api/sso').catch(() => ({
|
||||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||||
@@ -1489,10 +1507,133 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (always at the bottom of Settings).
|
||||
// Sourced from /api/docs so the hand-curated list stays the
|
||||
// single source of truth and the UI doesn't need its own copy
|
||||
// baked into the JS bundle.
|
||||
// v0.5.0: the API reference moved to the Advanced tab; Settings
|
||||
// now holds just account / SSO / branding.
|
||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
|
||||
},
|
||||
|
||||
// v0.5.0: Advanced settings — webhook/email notifications, and the
|
||||
// API reference (relocated from the bottom of Settings).
|
||||
advanced: async () => {
|
||||
const [notify, docs] = await Promise.all([
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
|
||||
// ── Notification config ──
|
||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
|
||||
const nKind = el('select', {}, [
|
||||
el('option', {value:'slack'}, 'Slack'),
|
||||
el('option', {value:'discord'}, 'Discord'),
|
||||
el('option', {value:'teams'}, 'Microsoft Teams'),
|
||||
el('option', {value:'smtp'}, 'Email (SMTP)'),
|
||||
]);
|
||||
nKind.value = notify.kind || 'slack';
|
||||
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
|
||||
value: notify.webhook_url || ''});
|
||||
// SMTP fields.
|
||||
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
|
||||
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
|
||||
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
|
||||
// GET returns the password as a redaction sentinel when one is
|
||||
// set; show an empty field with an "(unchanged)" placeholder and
|
||||
// let collectNotify() re-send the sentinel so the stored secret
|
||||
// is preserved unless the operator types a new one.
|
||||
const hasStoredPass = !!notify.smtp_password;
|
||||
const sPass = el('input', {type:'password',
|
||||
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
|
||||
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
|
||||
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
|
||||
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
|
||||
|
||||
const webhookBlock = el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||
el('span', {class:'name'}, 'Incoming webhook URL'),
|
||||
nWebhook,
|
||||
el('span', {class:'hint'},
|
||||
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
|
||||
]),
|
||||
]);
|
||||
const smtpBlock = el('div', {}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
|
||||
]),
|
||||
el('label', {class:'check', style:'margin-top:12px'}, [
|
||||
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
|
||||
]),
|
||||
]);
|
||||
// Toggle which provider block shows.
|
||||
const syncKind = () => {
|
||||
const smtp = nKind.value === 'smtp';
|
||||
webhookBlock.style.display = smtp ? 'none' : '';
|
||||
smtpBlock.style.display = smtp ? '' : 'none';
|
||||
};
|
||||
nKind.addEventListener('change', syncKind);
|
||||
syncKind();
|
||||
|
||||
const collectNotify = () => ({
|
||||
enabled: nEnabled.checked,
|
||||
kind: nKind.value,
|
||||
webhook_url: nWebhook.value,
|
||||
smtp_host: sHost.value,
|
||||
smtp_port: Number(sPort.value) || 587,
|
||||
smtp_username: sUser.value,
|
||||
// Empty field + a stored password → send the sentinel so the
|
||||
// server keeps it. Otherwise send whatever was typed (a new
|
||||
// password, or empty to clear when none was stored).
|
||||
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
|
||||
smtp_from: sFrom.value,
|
||||
smtp_to: sTo.value,
|
||||
smtp_implicit_tls: sTls.checked,
|
||||
});
|
||||
|
||||
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||
const r = await putJSON('/api/notify', collectNotify());
|
||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
|
||||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||
}}, 'Save notification settings');
|
||||
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
||||
onclick: async () => {
|
||||
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
||||
// Save first so the test uses exactly what's on screen.
|
||||
const rs = await putJSON('/api/notify', collectNotify());
|
||||
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
|
||||
const r = await postJSON('/api/notify/test', {});
|
||||
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
|
||||
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||
}}, 'Send test');
|
||||
|
||||
const notifyCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Webhook notifications'),
|
||||
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
|
||||
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
|
||||
'(or SMTP details), and Send test.'),
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
|
||||
]),
|
||||
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
|
||||
saveBtn, testBtn, nMsg,
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (relocated from Settings) ──
|
||||
const groups = docs.groups || [];
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
@@ -1515,12 +1656,41 @@
|
||||
'No API documentation returned by /api/docs.')),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
|
||||
return el('div', {class:'grid'}, [notifyCard, apiCard]);
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
|
||||
// ── Check for updates ──
|
||||
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||
const updBtn = el('button', {onclick: async () => {
|
||||
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
|
||||
try {
|
||||
const r = await getJSON('/api/updates/check');
|
||||
if (r.error) {
|
||||
updMsg.replaceChildren(document.createTextNode('Couldn’t check: ' + r.error));
|
||||
updMsg.className = 'msg err';
|
||||
} else if (r.update_available) {
|
||||
const parts = [document.createTextNode('Update available: '),
|
||||
el('strong', {}, r.latest), document.createTextNode(' (you’re on ' + r.current + '). ')];
|
||||
if (r.html_url) {
|
||||
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
|
||||
'View release →'));
|
||||
}
|
||||
updMsg.replaceChildren(...parts);
|
||||
updMsg.className = 'msg ok';
|
||||
} else {
|
||||
updMsg.replaceChildren(document.createTextNode(
|
||||
'You’re up to date — running the latest release (' + r.current + ').'));
|
||||
updMsg.className = 'msg ok';
|
||||
}
|
||||
} catch (e) {
|
||||
updMsg.textContent = 'Couldn’t reach the release feed (offline?).'; updMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Check for updates');
|
||||
|
||||
const heroCard = el('div', {class:'card'}, [
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'OpenPXE'),
|
||||
el('p', {class:'lead'},
|
||||
@@ -1535,6 +1705,7 @@
|
||||
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
||||
'https://openpxe.com/'),
|
||||
]),
|
||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
'executes is generated from the settings on these tabs — there is no ' +
|
||||
@@ -1547,6 +1718,46 @@
|
||||
'flip "testsigning" on a target machine.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── Licenses ──
|
||||
const licenseCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'License')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:12px'}, [
|
||||
'OpenPXE is dual-licensed under ',
|
||||
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
|
||||
el('strong', {}, 'Apache License 2.0'),
|
||||
document.createTextNode(
|
||||
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
|
||||
]),
|
||||
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
|
||||
el('div', {}, [
|
||||
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
|
||||
el('p', {class:'msg', style:'font-size:12px'},
|
||||
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
|
||||
'of this software and associated documentation files, to deal in the Software ' +
|
||||
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
|
||||
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
|
||||
'Full MIT text →'),
|
||||
]),
|
||||
el('div', {}, [
|
||||
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
|
||||
el('p', {class:'msg', style:'font-size:12px'},
|
||||
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
|
||||
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
|
||||
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
|
||||
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
|
||||
'Full Apache 2.0 text →'),
|
||||
]),
|
||||
]),
|
||||
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
|
||||
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
|
||||
'wimtools, and the Rust crates in this build. See the source repository for the ' +
|
||||
'complete NOTICE.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [heroCard, licenseCard]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1559,6 +1770,7 @@
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
advanced: 'Advanced',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
@@ -1663,6 +1875,26 @@
|
||||
let chipsInterval = null;
|
||||
let authScreenEl = null;
|
||||
let ssoConfig = null;
|
||||
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
|
||||
// /api/me (public). Lets the login/setup cards render the same
|
||||
// FleetDM-style full-width custom logo the dashboard sidebar uses.
|
||||
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
|
||||
|
||||
// Brand row for the auth screens. With a custom logo uploaded: the
|
||||
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
|
||||
// Default: bundled mark + "OpenPXE".
|
||||
function authBrandRow() {
|
||||
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
|
||||
if (brandInfo.has_custom_logo) {
|
||||
return el('div', {class:'brand-row has-custom-logo'}, [
|
||||
el('img', {src, alt:'logo'}),
|
||||
]);
|
||||
}
|
||||
return el('div', {class:'brand-row'}, [
|
||||
el('img', {src, alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]);
|
||||
}
|
||||
|
||||
function teardownAuthScreen() {
|
||||
if (authScreenEl && authScreenEl.parentNode) {
|
||||
@@ -1719,10 +1951,7 @@
|
||||
submit.textContent = 'Sign in';
|
||||
}
|
||||
}}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'Sign in'),
|
||||
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
|
||||
el('label', {class:'field'}, [
|
||||
@@ -1787,10 +2016,7 @@
|
||||
submit.textContent = 'Create administrator';
|
||||
}
|
||||
}}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'First-run setup'),
|
||||
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
|
||||
el('label', {class:'field'}, [
|
||||
@@ -1906,15 +2132,17 @@
|
||||
let me;
|
||||
try {
|
||||
me = await fetch('/api/me').then(r => r.json());
|
||||
// Capture branding so the auth cards render the custom logo.
|
||||
brandInfo = {
|
||||
has_custom_logo: !!me.has_custom_logo,
|
||||
logo_rev: me.logo_rev || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
// /api/me is unauthenticated in every state — if we can't reach
|
||||
// it the server is genuinely down, not an auth problem.
|
||||
document.body.appendChild(el('div', {class:'auth-screen'},
|
||||
el('div', {class:'auth-card'}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'Connection error'),
|
||||
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
|
||||
])));
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="settings">Settings</a>
|
||||
<a data-view="advanced">Advanced</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
|
||||
Reference in New Issue
Block a user