Files
OpenPXE/crates/openpxe/src/main.rs
T
Miles WardandClaude Opus 4.7 a1518110ed v0.4.5: VMware UEFI fix, static musl binary, Forms auth + SSO config
VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
  canonical Casper `iso-url=` option and `ds=nocloud`, matching the
  fix Bootimus shipped in v0.1.67. The previous
  `boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
  bare-metal UEFI but hung at "cloud-init running" on VMware guests
  because subiquity / cloud-init can't reach a metadata datasource
  through PXE.

Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
  x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
  dependency at all; the runtime stage still ships Debian slim for the
  samba/wimtools/nfs-common shellouts, but a future scratch/distroless
  variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
  found" surprises on older RHEL/Rocky hosts.

Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
  <work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
  current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
  with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
  /api/logout, GET /api/me, PUT /api/me/credentials (rotates and
  revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
  passes through entirely until then (tests + fresh installs ride this
  path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
  /api/queue/join, /api/queue/poll/*) so iPXE clients still work
  without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
  footer, Account card in Settings for rotating creds. Auth screen is
  fully styled (centered narrow card, matches Sonarr layout).

SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
  metadata_url } persisted to <work_dir>/sso.json with size caps and
  URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
  without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
  inert "Sign in with X" button on the login screen while runtime
  flow is pending. Per the brief: no Entity ID field (defaults to the
  advertised public_base_url internally when SAML wiring lands).

Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
  unit tests, +7 auth integration tests, +1 SSO integration test, +1
  regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-25 22:37:20 -04:00

437 lines
16 KiB
Rust

//! 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<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[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=<your-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<SocketAddr>` extractors can resolve the peer IP —
// used by `/boot/<entry>.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::<std::net::SocketAddr>(),
)
.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<anyhow::Result<()>> = 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<Ipv4Addr> {
// 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<String> {
// 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<LogBus>) {
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::<u8>() {
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
)
}