Codebase-wide review pass: finish or remove every loose end, take the safe performance wins on the serving hot paths, and refresh the dependency tree for reliability. No behavior changes for working clients; legacy clients get clearer protocol errors. Finalize / cleanup: - Remove mac_allowlist/subnet_allowlist config fields — parsed but never enforced since introduction; the operator wants line-of-sight serving, so the honest fix is deletion, not wiring. - Remove dead ClientRegistry API (get, set_selected_target, always-None selected_target field, never-emitted DhcpRequest/ HttpIsoAsset events). - TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear error instead of silent timeouts (legacy-client friendliness); fold plan_window into cfg(test); drop the unused-constant keep-alive hack. - rustfmt sweep over the six files with accumulated drift. Hot-path optimizations (all behavior-preserving): - Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP and HTTP — was a ~1 MiB heap copy per boot file request. - Cache the composited PXE boot-menu background PNG keyed on the branding logo revision — was ~50-200 ms of image work per booting client; now one compose per logo change. - Run bcrypt verify/hash on the blocking pool (boot password gate, login, setup, credential rotation) so CPU-heavy auth can't stall the workers streaming ISO ranges to imaging machines. - iso_raw: reuse the already-cloned IsoMeta for path resolution instead of a second registry lock + deep clone per range request. - DriverEscalation: amortize the TTL sweep (1-min interval + inline staleness check) instead of an O(map) retain per DHCP packet. - format_mac: one allocation instead of four per datagram. - Introspection haystack sized to min(scan cap, file size) — was guaranteed a 32 MiB realloc on every large-ISO probe. Robustness: - parse_range: malformed Range headers are now ignored per RFC 7233 (200 + full body) instead of answered with a bogus 206. Dependencies: - dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained trust-dns-proto from the tree (hickory-proto), three releases of DHCP option coverage. Compiles + passes the full suite unchanged. - socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom workspace entry removed; requested versions synced to shipped reality. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
290 lines
11 KiB
Rust
290 lines
11 KiB
Rust
use figment::providers::{Env, Format, Serialized, Toml};
|
|
use figment::Figment;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Config {
|
|
pub server: ServerConfig,
|
|
pub network: NetworkConfig,
|
|
pub paths: Paths,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ServerConfig {
|
|
/// Address the web/API server binds to.
|
|
pub http_bind: IpAddr,
|
|
/// Port for the web/API + ISO/iPXE HTTP server (single port, multiplexed by path).
|
|
pub http_port: u16,
|
|
/// Address the TFTP server binds to.
|
|
pub tftp_bind: IpAddr,
|
|
/// Port for TFTP (RFC 1350 default is 69).
|
|
pub tftp_port: u16,
|
|
/// External hostname/IP clients should use to reach this server. If
|
|
/// `None`, auto-detect from the interface that received the DHCP request
|
|
/// (via IP_PKTINFO). This is what ends up in DHCP option 54 / siaddr,
|
|
/// option 66 (TFTP server), and the base of generated iPXE URLs.
|
|
pub public_ip: Option<Ipv4Addr>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct NetworkConfig {
|
|
pub dhcp_mode: DhcpMode,
|
|
/// Address the DHCP proxy/server binds to. For proxy mode, usually 0.0.0.0.
|
|
pub dhcp_bind: IpAddr,
|
|
/// UDP port for DHCP server-side receive. Standard is 67.
|
|
pub dhcp_port: u16,
|
|
/// UDP port for PXE Boot Server discovery. Standard is 4011.
|
|
pub pxe_port: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DhcpMode {
|
|
/// Run as DHCP proxy (RFC 4578): reply with boot info only, don't lease
|
|
/// IPs. Coexists with an existing DHCP server on the network. Default
|
|
/// because it's the only mode that works in most real deployments without
|
|
/// taking over address assignment.
|
|
#[default]
|
|
Proxy,
|
|
/// Disabled — rely on an external DHCP server that has been manually
|
|
/// configured with `next-server` / `filename`. OpenPXE only serves TFTP
|
|
/// + HTTP in this mode. Useful for home routers that can be pre-set.
|
|
// `off`/`none` are accepted as aliases for backward-compat with the old
|
|
// hand-rolled `apply_env`, which mapped them to Disabled.
|
|
#[serde(alias = "off", alias = "none")]
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Paths {
|
|
/// Directory holding uploaded ISO files.
|
|
pub iso_dir: PathBuf,
|
|
/// Directory for extracted kernel/initrd and other per-ISO derived assets.
|
|
pub work_dir: PathBuf,
|
|
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
|
|
pub ipxe_dir: PathBuf,
|
|
/// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
|
|
pub wimboot_path: Option<PathBuf>,
|
|
/// Directory under which Windows ISOs are extracted and served via SMB.
|
|
/// Only used when `settings.windows_enabled = true`. Defaults to
|
|
/// `/var/lib/openpxe/smb` in the container image.
|
|
pub smb_dir: PathBuf,
|
|
/// v0.5.2: directory holding uploaded unattended-install answer files
|
|
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
|
|
/// separate from `iso_dir` so answer files never appear in the ISO
|
|
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
|
|
pub unattended_dir: PathBuf,
|
|
}
|
|
|
|
impl Default for ServerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
http_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
|
http_port: 80,
|
|
tftp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
|
tftp_port: 69,
|
|
public_ip: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for NetworkConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
dhcp_mode: DhcpMode::Proxy,
|
|
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
|
dhcp_port: 67,
|
|
pxe_port: 4011,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Paths {
|
|
fn default() -> Self {
|
|
Self {
|
|
iso_dir: PathBuf::from("/var/lib/openpxe/isos"),
|
|
work_dir: PathBuf::from("/var/lib/openpxe/work"),
|
|
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
|
|
wimboot_path: None,
|
|
smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
|
|
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// `Config` derives `Default` because each component supplies its own
|
|
// non-trivial defaults via `impl Default` blocks above; deriving keeps
|
|
// this in sync if a new section is added.
|
|
|
|
impl Config {
|
|
pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
|
|
let text = std::fs::read_to_string(path)?;
|
|
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
|
|
}
|
|
|
|
/// Load configuration with layered precedence (v0.5.4, via `figment`):
|
|
/// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
|
|
/// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
|
|
/// and now covers **every** field automatically (the previous hand-rolled
|
|
/// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
|
|
///
|
|
/// The env layer preserves the historical flat names
|
|
/// (`OPENPXE_HTTP_PORT`, `OPENPXE_ISO_DIR`, …) so existing deployments
|
|
/// (the Unraid template, `entrypoint.sh`) keep working unchanged, and
|
|
/// additionally accepts the explicit nested form
|
|
/// `OPENPXE_<SECTION>__<FIELD>` (double underscore).
|
|
pub fn load(path: Option<&Path>) -> crate::Result<Self> {
|
|
let mut fig = Figment::from(Serialized::defaults(Config::default()));
|
|
if let Some(p) = path {
|
|
if p.exists() {
|
|
fig = fig.merge(Toml::file(p));
|
|
}
|
|
}
|
|
fig = fig.merge(env_provider());
|
|
fig.extract()
|
|
.map_err(|e| crate::Error::Config(e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// The `OPENPXE_*` environment provider. Maps the historical flat variable
|
|
/// names onto the nested [`Config`] fields, and also accepts the explicit
|
|
/// `OPENPXE_SECTION__FIELD` nested form. Keys that match nothing (e.g.
|
|
/// `OPENPXE_CONFIG`, `OPENPXE_UID` from the entrypoint) become stray
|
|
/// top-level keys that `Config` ignores on extract.
|
|
fn env_provider() -> Env {
|
|
Env::prefixed("OPENPXE_")
|
|
.map(|key| {
|
|
// Lowercase so the match is robust regardless of how the OS
|
|
// reports the var's case.
|
|
let k = key.as_str().to_ascii_lowercase();
|
|
let mapped = match k.as_str() {
|
|
"http_port" => "server.http_port",
|
|
"http_bind" => "server.http_bind",
|
|
"tftp_port" => "server.tftp_port",
|
|
"tftp_bind" => "server.tftp_bind",
|
|
"public_ip" => "server.public_ip",
|
|
"dhcp_port" => "network.dhcp_port",
|
|
"dhcp_bind" => "network.dhcp_bind",
|
|
"dhcp_mode" => "network.dhcp_mode",
|
|
"pxe_port" => "network.pxe_port",
|
|
"iso_dir" => "paths.iso_dir",
|
|
"work_dir" => "paths.work_dir",
|
|
"ipxe_dir" => "paths.ipxe_dir",
|
|
"smb_dir" => "paths.smb_dir",
|
|
"wimboot_path" => "paths.wimboot_path",
|
|
"unattended_dir" => "paths.unattended_dir",
|
|
// Unknown: support the explicit nested form
|
|
// (OPENPXE_SERVER__HTTP_PORT). `replace` is a no-op for the
|
|
// already-handled flat names above.
|
|
other => return other.replace("__", ".").into(),
|
|
};
|
|
mapped.into()
|
|
})
|
|
.split(".")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
|
|
// and `figment::Error` is large; that's the library's API, not ours.
|
|
#![allow(clippy::result_large_err)]
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_load_when_no_file_or_env() {
|
|
figment::Jail::expect_with(|_jail| {
|
|
let c = Config::load(None).expect("load defaults");
|
|
assert_eq!(c.server.http_port, 80);
|
|
assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
|
|
assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_flat_env_vars_still_apply() {
|
|
figment::Jail::expect_with(|jail| {
|
|
jail.set_env("OPENPXE_HTTP_PORT", "8123");
|
|
jail.set_env("OPENPXE_TFTP_PORT", "6900");
|
|
jail.set_env("OPENPXE_DHCP_PORT", "6767");
|
|
jail.set_env("OPENPXE_PXE_PORT", "4444");
|
|
jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
|
|
jail.set_env("OPENPXE_DHCP_MODE", "disabled");
|
|
jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
|
|
jail.set_env("OPENPXE_WORK_DIR", "/data/work");
|
|
jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
|
|
jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
|
|
// v0.5.4: a field the old apply_env never covered.
|
|
jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
|
|
let c = Config::load(None).expect("load with env");
|
|
assert_eq!(c.server.http_port, 8123);
|
|
assert_eq!(c.server.tftp_port, 6900);
|
|
assert_eq!(c.network.dhcp_port, 6767);
|
|
assert_eq!(c.network.pxe_port, 4444);
|
|
assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
|
|
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
|
|
assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
|
|
assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
|
|
assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
|
|
assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
|
|
assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn dhcp_mode_off_alias_maps_to_disabled() {
|
|
figment::Jail::expect_with(|jail| {
|
|
jail.set_env("OPENPXE_DHCP_MODE", "off");
|
|
let c = Config::load(None).unwrap();
|
|
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn nested_double_underscore_form_also_works() {
|
|
figment::Jail::expect_with(|jail| {
|
|
jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
|
|
let c = Config::load(None).unwrap();
|
|
assert_eq!(c.server.http_port, 9001);
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn env_overrides_toml_file() {
|
|
figment::Jail::expect_with(|jail| {
|
|
jail.create_file(
|
|
"openpxe.toml",
|
|
"[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
|
|
)?;
|
|
jail.set_env("OPENPXE_HTTP_PORT", "8443");
|
|
let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
|
|
// env wins over TOML…
|
|
assert_eq!(c.server.http_port, 8443);
|
|
// …but TOML-only values still apply.
|
|
assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_openpxe_env_vars_are_ignored() {
|
|
figment::Jail::expect_with(|jail| {
|
|
// entrypoint.sh sets these; they must not break config load.
|
|
jail.set_env("OPENPXE_UID", "10001");
|
|
jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
|
|
let c = Config::load(None).expect("stray vars ignored");
|
|
assert_eq!(c.server.http_port, 80);
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|