//! OpenPXE entry point. Wires the three protocol servers (DHCP proxy, //! TFTP, HTTP) to the shared ISO store and client registry, then runs //! them concurrently. use clap::{Parser, Subcommand}; use openpxe_core::{ ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics, SettingsStore, }; use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_http_api::{build_router, AppState}; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use tokio::io::AsyncReadExt; #[derive(Debug, Parser)] #[command(name = "openpxe", about = "Container-native PXE boot server", version)] struct Cli { /// Path to a TOML config file. All fields have sensible defaults and can /// also be overridden with env vars (OPENPXE_*). #[arg(long, env = "OPENPXE_CONFIG")] config: Option, #[command(subcommand)] command: Option, } #[derive(Debug, Subcommand)] enum Command { /// Import every `.iso` from a host directory into the ISO store, running /// the same introspection + boot-entry generation pass the web upload /// does. Useful for pre-populating the store before starting the server /// (e.g. in a CI setup or after copying ISOs to a fresh PVC). /// /// Example: /// docker run --rm \ /// -v /my/isos:/seed:ro \ /// -v openpxe-data:/var/lib/openpxe/isos \ /// openpxe:0.4.1 seed --from /seed Seed { /// Source directory containing one or more `.iso` files. #[arg(long)] from: PathBuf, /// Don't actually import — print what would happen. #[arg(long)] dry_run: bool, }, } #[tokio::main] async fn main() -> anyhow::Result<()> { // The LogBus has to exist before we install tracing layers, since one // of those layers fans out into it. The web UI's Terminal tab // subscribes to this bus over SSE. let log_bus = LogBus::new(500); init_tracing(log_bus.clone()); let cli = Cli::parse(); let mut config = match &cli.config { Some(p) if p.exists() => Config::from_toml_file(p)?, _ => Config::default(), }; config.apply_env(); // Dispatch subcommands before bringing up the server. if let Some(cmd) = cli.command { return run_command(cmd, config).await; } openpxe_ipxe_assets::log_availability(); let our_ip = match config.server.public_ip { Some(ip) => { tracing::info!("using configured public IP: {ip}"); ip } None => match detect_primary_ipv4() { Some(ip) => { tracing::info!("auto-detected public IPv4: {ip}"); ip } None => { // Without a reachable IP, every generated iPXE URL would // be unreachable from real clients. Exit with a clear // message instead of serving a broken deployment. anyhow::bail!( "could not detect a non-loopback IPv4 address for this host. \ Set OPENPXE_PUBLIC_IP= (e.g. `-e OPENPXE_PUBLIC_IP=10.0.0.5` \ in docker, or the env block in OpenShift Deployment) to advertise \ a specific IP to PXE clients." ); } }, }; let public_base_url = format!("http://{our_ip}"); let iso_store = IsoStore::new(config.paths.iso_dir.clone()); iso_store.load_from_disk().await?; let clients = ClientRegistry::new(); let queue = DeploymentQueue::new(); let settings = SettingsStore::load_or_default(&config.paths.work_dir); let hosts = HostBindings::load_or_default(&config.paths.work_dir); let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir); 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 sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); // Build the SMB manager unconditionally — it starts/stops on the // Windows toggle, not at process start. If the `smb_dir` isn't // writable (e.g. read-only filesystem), the manager will surface that // as `SmbState::Failed` when the operator flips the toggle. let smb = Arc::new(SmbManager::new(config.paths.smb_dir.clone())); if settings.snapshot().windows_enabled { let _ = smb.start(); } // NFS manager. The mount root has to be set on the IsoStore *before* // we replay any persisted mounts, otherwise an in-memory IsoMeta // pointing at an NFS source can't resolve to a path. let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); iso_store.set_nfs_root(nfs.mount_root()); if let Err(e) = nfs.load_and_remount().await { tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); } // Sniff network details for the Network tab. None of these are // required for PXE to work — they're informational, surfaced in the // UI so an operator doesn't have to drop to a shell to find their // own gateway. let net = detect_network_info(our_ip); tracing::info!( target: "openpxe::net", nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway, "network info" ); let state = AppState { iso_store: iso_store.clone(), clients: clients.clone(), settings: settings.clone(), queue: queue.clone(), hosts: hosts.clone(), boot_log: boot_log.clone(), branding: branding.clone(), admin: admin.clone(), sessions: sessions.clone(), sso: sso.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), started_at: time::OffsetDateTime::now_utc(), public_base_url: public_base_url.clone(), nic_name: net.nic_name, subnet_mask: net.subnet_mask, gateway: net.gateway, }; let http_addr = SocketAddr::new(config.server.http_bind, config.server.http_port); let router = build_router(state); let http_task = tokio::spawn(async move { let listener = tokio::net::TcpListener::bind(http_addr).await?; tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}"); // `into_make_service_with_connect_info` is required so per-request // `ConnectInfo` extractors can resolve the peer IP — // used by `/boot/.ipxe` to record the booting client's // address into the Host log. Without this the extractor 500s. axum::serve( listener, router.into_make_service_with_connect_info::(), ) .await?; Ok::<_, anyhow::Error>(()) }); let tftp = TftpServer::new( config.server.tftp_bind, config.server.tftp_port, clients.clone(), metrics.clone(), ); let tftp_task = tokio::spawn(tftp.run()); let dhcp_task: tokio::task::JoinHandle> = match config.network.dhcp_mode { DhcpMode::Proxy => { let s = DhcpProxyServer::new( config.network.dhcp_bind, config.network.dhcp_port, config.network.pxe_port, our_ip, public_base_url.clone(), clients.clone(), metrics.clone(), ); tokio::spawn(s.run()) } DhcpMode::Disabled => { tracing::info!(target: "openpxe::dhcp", "DHCP disabled — external DHCP must set next-server + filename"); tokio::spawn(async { futures_forever().await }) } }; tokio::select! { r = http_task => { tracing::error!("http task exited: {:?}", r); r??; } r = tftp_task => { tracing::error!("tftp task exited: {:?}", r); r??; } r = dhcp_task => { tracing::error!("dhcp task exited: {:?}", r); r??; } } Ok(()) } async fn futures_forever() -> anyhow::Result<()> { std::future::pending::<()>().await; Ok(()) } async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> { match cmd { Command::Seed { from, dry_run } => seed_from_dir(&from, &config, dry_run).await, } } /// Walk `src` for `*.iso`, stream each file through the normal upload path. /// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk /// is identical to a web upload — same slug rules, same introspection, same /// sha256. async fn seed_from_dir( src: &std::path::Path, config: &Config, dry_run: bool, ) -> anyhow::Result<()> { let store = IsoStore::new(config.paths.iso_dir.clone()); store.load_from_disk().await?; let mut entries = tokio::fs::read_dir(src).await?; let mut imported = 0u32; let mut skipped = 0u32; while let Some(entry) = entries.next_entry().await? { let p = entry.path(); if p.extension() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase) .as_deref() != Some("iso") { continue; } let filename = p .file_name() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))? .to_string(); println!( " {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len() ); if dry_run { continue; } let mut handle = match store.begin_upload(&filename).await { Ok(h) => h, Err(openpxe_core::Error::Invalid(e)) => { eprintln!(" skip: {e}"); skipped += 1; continue; } Err(e) => return Err(e.into()), }; let mut file = tokio::fs::File::open(&p).await?; let mut buf = vec![0u8; 1024 * 1024]; loop { let n = file.read(&mut buf).await?; if n == 0 { break; } let chunk: bytes::Bytes = buf[..n].to_vec().into(); handle.write_chunk(&chunk).await?; } let meta = handle.finish(&store).await?; println!( " -> id={} family={:?}", meta.id, meta.introspection.family ); imported += 1; } println!( "\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" } ); Ok(()) } /// Pick the first non-loopback IPv4 address on this host. Returns `None` if /// detection fails — callers should fail startup rather than silently using /// a loopback address (which would give every PXE client an unreachable /// `http://127.0.0.1/...`). Users in multi-homed setups should set /// `OPENPXE_PUBLIC_IP` explicitly. fn detect_primary_ipv4() -> Option { // First try: route to the public internet. `UdpSocket::connect` to a // well-known external address causes the OS to populate `local_addr` // with the source IP it would use — this is the standard "which of my // interfaces is the internet-facing one" idiom. if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") { if sock.connect("8.8.8.8:80").is_ok() { if let Ok(std::net::SocketAddr::V4(addr)) = sock.local_addr() { let v4 = *addr.ip(); if !v4.is_loopback() && !v4.is_unspecified() { return Some(v4); } } } } // Fallback: hostname resolution. if let Ok(hostname) = hostname() { if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&format!("{hostname}:0")) { for a in addrs { if let std::net::IpAddr::V4(v4) = a.ip() { if !v4.is_loopback() && !v4.is_unspecified() { return Some(v4); } } } } } None } fn hostname() -> std::io::Result { // Tiny shim: read /proc/sys/kernel/hostname on Linux, fall back to `uname -n` via env. if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") { return Ok(h.trim().to_string()); } std::env::var("HOSTNAME") .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname")) } fn init_tracing(bus: Arc) { use tracing_subscriber::{fmt, prelude::*, EnvFilter}; let filter = EnvFilter::try_from_env("OPENPXE_LOG") .unwrap_or_else(|_| EnvFilter::new("info,openpxe=debug")); tracing_subscriber::registry() .with(filter) .with(fmt::layer().with_target(true)) .with(LogBusLayer::new(bus)) .init(); } #[derive(Debug, Default)] struct NetworkInfo { nic_name: String, subnet_mask: String, gateway: String, } /// Best-effort population of the Network tab's read-only fields. We shell /// out to standard Linux tools (`ip route`) instead of pulling in a /// netlink crate — these calls run once at startup, so the cost of /// spawning a process is negligible. Empty strings are perfectly fine /// fallbacks; the UI shows a `?` placeholder. fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo { use std::process::Command; let mut info = NetworkInfo::default(); // `ip -o -f inet addr show` lists every interface with its // `inet a.b.c.d/mask`. We match the line that mentions our IP. if let Ok(out) = Command::new("ip") .args(["-o", "-f", "inet", "addr", "show"]) .output() { if let Ok(text) = String::from_utf8(out.stdout) { for line in text.lines() { if !line.contains(&our_ip.to_string()) { continue; } // Format: "2: enp1s0 inet 10.0.0.5/24 brd ..." let mut parts = line.split_whitespace(); let _idx = parts.next(); if let Some(name) = parts.next() { info.nic_name = name.trim_end_matches(':').to_string(); } if let Some(addr) = line.split_whitespace().find(|p| p.contains('/')) { if let Some((_, prefix_str)) = addr.split_once('/') { if let Ok(prefix) = prefix_str.parse::() { info.subnet_mask = prefix_to_dotted(prefix); } } } break; } } } // `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..." if let Ok(out) = Command::new("ip") .args(["route", "show", "default"]) .output() { if let Ok(text) = String::from_utf8(out.stdout) { if let Some(line) = text.lines().next() { let mut parts = line.split_whitespace(); while let Some(p) = parts.next() { if p == "via" { if let Some(gw) = parts.next() { info.gateway = gw.to_string(); } break; } } } } } info } fn prefix_to_dotted(prefix: u8) -> String { let prefix = prefix.min(32); let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) }; format!( "{}.{}.{}.{}", (mask >> 24) & 0xff, (mask >> 16) & 0xff, (mask >> 8) & 0xff, mask & 0xff ) }