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, } #[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, /// 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 { 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_
__` (double underscore). pub fn load(path: Option<&Path>) -> crate::Result { 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(()) }); } }