Initial commit: PXEForge Phases 1-4
Container-native PXE boot server in Rust, designed as a clean-room alternative to iVentoy that never touches the client OS trust store. This is the first commit of the project; it lands the full output of Phases 1, 2, 3, and 4 in one shot. ## Phase 1 — protocol stack - 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store, ipxe-assets, webui, pxeforge bin). - DHCP proxy (RFC 4578): replies with boot info only, never leases — sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64). - TFTP server with full OACK negotiation: blksize, tsize, windowsize. Without it a 1 MiB iPXE binary takes 2000 packets and unusably long. - Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd. - HTTP server (axum) with byte-Range ISO streaming and an in-place ISO9660 lookup so kernel/initrd are served from inside the ISO without ever extracting it to disk. - Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail for >1-2 GiB modern distros). Distro-family detection drives the cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine). ## Phase 2 — UX + Windows - Hierarchical PXE menu (Default / Installers / Tools / Gated Deployment) generated from settings — no hand-written .ipxe paths surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants for some RHEL ISOs. - Gated Deployment "horse-race" queue: clients join, operator picks one ISO, every gate launches simultaneously via tokio::sync::Notify. - Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd into boot.wim so vanilla WinPE net-uses an SMB share and runs setup.exe. All Microsoft-signed; no test certs, no testsigning, no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP. - Netbox-style dark UI, fully offline (no CDN, no external fonts). ## Phase 3 — MVP hardening - TFTP retransmit rewrite with explicit window tracking — UEFI SNP clients no longer hang on files that end mid-window. 4 new tests. - DHCP broadcast-flag honored per RFC 2131 §4.1. - Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns bind-mounts as root then drops to uid 10001 via gosu. - /healthz + /readyz split from /api/status — readyz fails if no iPXE binaries are bundled. - pxeforge seed --from <path> CLI: same pipeline as web upload (slug, sha256, introspection, boot-entry). - All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple). - Gate poll retains assignment until operator releases — clients that retry on transient network errors reuse the assignment instead of falling back to the menu. - Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no NET_RAW. ## Phase 4 — UI restructure + remote storage - Web UI rebuilt around six tabs inspired by the iVentoy layout: Dashboard / Network / Forge Gate / Storage / Terminal / About. Old "Monitoring/Content/Configuration" sidebar groups are gone. - NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or NFSv4.1 shares as ISO sources instead of uploading every file into the PVC. New IsoSource enum on IsoMeta lets the store resolve Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed mounts surface in the UI rather than blocking startup. - Dockerfile gains nfs-common + iproute2; mounting NFS in-container also requires CAP_SYS_ADMIN. Documented in docs/architecture.md. - LogBus + tracing layer in core: 500-line ring buffer + broadcast channel feed an SSE endpoint at /api/log/stream. - Operator terminal at /api/terminal: whitelisted commands (status, isos, clients, gate, nfs, smb, log) — deliberately not a shell. Output mirrored onto the LogBus so the live tail and the terminal pane share one timeline. - Network tab: read-only nic_name / subnet_mask / gateway probed from `ip` at startup; only DNS server is editable. Editing IP/mask on a hot UI would silently break PXE for every client mid-boot. - Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on un-bootable ISOs with inline reasons, dashboard "won't boot" panel. ## Tests 56 tests passing across the workspace: - 16 core (LogBus, gate, settings, arch, client) - 1 dhcp-proxy (raw option-93 extraction) - 8 http-api unit (range parsing, terminal split/format) - 13 http-api integration (gated deployment, range, settings, NFS, terminal, log SSE, network endpoint, ui assets, no-external-urls) - 12 iso-store (introspect, slugify, smb, windows wim, NFS options) - 6 tftp (RRQ parsing, plan_window edges) cargo build --workspace and cargo clippy --workspace --all-targets both finish clean (warnings only, no errors).
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
/* PXEForge web UI — Netbox-style layout, fully offline.
|
||||
* Design tokens are CSS variables so a later phase can re-theme without
|
||||
* touching markup or JS. */
|
||||
|
||||
:root {
|
||||
--bg: #0b1018;
|
||||
--bg-panel: #121826;
|
||||
--bg-panel-2: #1a2334;
|
||||
--bg-elev: #223047;
|
||||
--fg: #e4e8ef;
|
||||
--fg-dim: #8a94a7;
|
||||
--fg-dimmer: #5a6379;
|
||||
--accent: #00d4b4; /* Netbox-ish teal */
|
||||
--accent-dim: #07a38c;
|
||||
--warn: #ffb347;
|
||||
--err: #ef6e6e;
|
||||
--ok: #4ade80;
|
||||
--border: #223047;
|
||||
--border-soft: #172033;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 54px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
|
||||
background: var(--bg); color: var(--fg);
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
background: var(--bg-panel-2); padding: 1px 5px; border-radius: 3px; }
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar .brand {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar .brand img { width: 40px; height: auto; }
|
||||
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
|
||||
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
|
||||
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
||||
.sidebar nav .group {
|
||||
padding: 10px 18px 6px;
|
||||
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.sidebar nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 18px; color: var(--fg); font-size: 13.5px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
|
||||
.sidebar nav a.active {
|
||||
background: var(--bg-panel-2);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.sidebar nav a .count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-elev); color: var(--fg-dim);
|
||||
padding: 1px 7px; font-size: 11px; border-radius: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sidebar nav a.active .count { background: var(--accent); color: #002923; }
|
||||
.sidebar .footer {
|
||||
padding: 10px 18px; border-top: 1px solid var(--border);
|
||||
color: var(--fg-dimmer); font-size: 11px;
|
||||
}
|
||||
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
|
||||
|
||||
/* ── Top bar ───────────────────────────────────────────────────────── */
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 20px; gap: 18px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--fg); letter-spacing: 0.2px;
|
||||
}
|
||||
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
|
||||
.topbar .tabs button {
|
||||
background: transparent; border: 0;
|
||||
color: var(--fg-dim); font: inherit;
|
||||
padding: 10px 14px; cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.topbar .tabs button:hover { color: var(--fg); }
|
||||
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .chip {
|
||||
background: var(--bg-panel-2); border: 1px solid var(--border);
|
||||
color: var(--fg-dim); font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 12px;
|
||||
}
|
||||
.topbar .chip strong { color: var(--fg); font-weight: 600; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────── */
|
||||
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
padding: 22px 26px 40px;
|
||||
}
|
||||
.grid { display: grid; gap: 20px; }
|
||||
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
@media (max-width: 1024px) {
|
||||
.grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Cards / panels ───────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card > header {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.card > header h2 { margin: 0; font-size: 13.5px; font-weight: 600; color: var(--fg); }
|
||||
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
|
||||
.card .body { padding: 16px; }
|
||||
|
||||
.stat {
|
||||
padding: 16px;
|
||||
}
|
||||
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
|
||||
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
|
||||
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
|
||||
|
||||
/* ── Tables ───────────────────────────────────────────────────────── */
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--border-soft); }
|
||||
th {
|
||||
color: var(--fg-dim); font-weight: 500; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
tr:hover td { background: var(--bg-panel-2); }
|
||||
td.mono { font-family: var(--mono); font-size: 12.5px; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Tags / pills ─────────────────────────────────────────────────── */
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
background: #1b3148; color: #a2c5e8;
|
||||
}
|
||||
.tag.ok { background: #103428; color: var(--ok); }
|
||||
.tag.warn { background: #3a2a10; color: var(--warn); }
|
||||
.tag.err { background: #3a1515; color: var(--err); }
|
||||
.tag.accent { background: #072f29; color: var(--accent); }
|
||||
.tag.arch { text-transform: uppercase; }
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||
|
||||
button, .btn {
|
||||
background: var(--accent); color: #002923;
|
||||
border: 0; border-radius: var(--radius);
|
||||
padding: 7px 14px; font: inherit; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
|
||||
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
|
||||
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; }
|
||||
button.danger:hover { background: #2a0b0b; color: var(--err); }
|
||||
|
||||
label.field {
|
||||
display: grid; gap: 4px; margin-bottom: 14px;
|
||||
}
|
||||
label.field .name { color: var(--fg-dim); font-size: 12px; }
|
||||
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
|
||||
label.field input[type="text"],
|
||||
label.field input[type="number"],
|
||||
label.field select,
|
||||
label.field textarea {
|
||||
width: 100%; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 7px 10px; font: inherit;
|
||||
}
|
||||
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
}
|
||||
label.check {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius);
|
||||
}
|
||||
label.check input { accent-color: var(--accent); }
|
||||
|
||||
/* ── Drop zone ────────────────────────────────────────────────────── */
|
||||
|
||||
.drop {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px; text-align: center;
|
||||
color: var(--fg-dim); cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.drop.hover, .drop:hover {
|
||||
border-color: var(--accent); color: var(--fg);
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
.drop strong { color: var(--accent); }
|
||||
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
|
||||
.progress.active { display: block; }
|
||||
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
|
||||
|
||||
/* ── Gate queue "horse race" visual ───────────────────────────────── */
|
||||
|
||||
.gate-track {
|
||||
display: grid; gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.gate-row {
|
||||
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel-2); border-radius: var(--radius);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.gate-row.assigned { border-left-color: var(--ok); }
|
||||
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
|
||||
.gate-row.assigned .pos { color: var(--ok); }
|
||||
.gate-row .mac { font-family: var(--mono); font-size: 13px; }
|
||||
.gate-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
|
||||
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
|
||||
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
|
||||
.msg.err { color: var(--err); }
|
||||
.msg.ok { color: var(--ok); }
|
||||
|
||||
/* ── Top bar readiness chip ──────────────────────────────────────── */
|
||||
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; }
|
||||
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; }
|
||||
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; }
|
||||
|
||||
/* ── Dashboard stat strip ────────────────────────────────────────── */
|
||||
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px;
|
||||
padding: 4px 0; font-size: 13px; }
|
||||
.kv .k { color: var(--fg-dim); }
|
||||
.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; }
|
||||
.kv .v.warn { color: var(--warn); }
|
||||
.kv .v.err { color: var(--err); }
|
||||
.kv .v.ok { color: var(--ok); }
|
||||
|
||||
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */
|
||||
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; }
|
||||
tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
|
||||
|
||||
/* ── Table source badge ──────────────────────────────────────────── */
|
||||
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
|
||||
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
|
||||
.src-badge.nfs { background: #122a3a; color: #7cd3ff; }
|
||||
|
||||
/* ── NFS modal-ish add form ──────────────────────────────────────── */
|
||||
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
|
||||
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
|
||||
border-left: 3px solid var(--accent); border-radius: var(--radius); }
|
||||
.nfs-row.down { border-left-color: var(--err); }
|
||||
.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
||||
.nfs-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.err { background: var(--err); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
|
||||
/* ── Inline form rows (used by Network + NFS add) ───────────────── */
|
||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Terminal pane ──────────────────────────────────────────────── */
|
||||
.terminal {
|
||||
display: flex; flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #06090e;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - var(--topbar-h) - 90px);
|
||||
min-height: 480px;
|
||||
}
|
||||
.terminal .pane {
|
||||
flex: 1; overflow: auto;
|
||||
padding: 10px 14px;
|
||||
font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
|
||||
color: #cfd6e2;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.terminal .pane .lvl-error { color: var(--err); }
|
||||
.terminal .pane .lvl-warn { color: var(--warn); }
|
||||
.terminal .pane .lvl-info { color: #cfd6e2; }
|
||||
.terminal .pane .lvl-debug { color: var(--fg-dim); }
|
||||
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
|
||||
.terminal .pane .ts { color: var(--fg-dimmer); }
|
||||
.terminal .pane .tg { color: #7cd3ff; }
|
||||
.terminal .pane .echo { color: var(--accent); }
|
||||
.terminal .input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #0a0e15;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
||||
.terminal .input-row input {
|
||||
flex: 1; background: transparent; border: 0; color: var(--fg);
|
||||
font: inherit; font-family: var(--mono); font-size: 13px;
|
||||
outline: none; padding: 4px 0;
|
||||
}
|
||||
.terminal .toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--fg-dim);
|
||||
}
|
||||
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.terminal .toolbar button {
|
||||
padding: 3px 9px; font-size: 11px;
|
||||
background: transparent; color: var(--fg-dim); border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
|
||||
|
||||
/* ── About card ─────────────────────────────────────────────────── */
|
||||
.about-hero { padding: 20px 24px; }
|
||||
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
|
||||
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
|
||||
.about-hero .who { margin-top: 18px; font-size: 13px; }
|
||||
.about-hero .who span { color: var(--fg-dim); }
|
||||
.about-hero .who strong { color: var(--accent); }
|
||||
Reference in New Issue
Block a user