Compare commits

..
27 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 1eb41288c3 v0.4.69: PNG boot-menu background (iPXE built from source), NFS AUTH_SYS, FleetDM logo
Three things, headlined by the long-blocked graphical PXE menu.

## 1. Graphical PXE boot background — the iVentoy feature, finally

iVentoy paints a PNG background on the PXE screen using stock iPXE
built with CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public
iPXE binaries omit those, so `console --picture` is a no-op on them.
We now build our own iPXE from upstream with that thin config delta
(deploy/ipxe/local/{general,console}.h).

The 8-release blocker was cc1 segfaulting when an amd64 gcc ran under
QEMU emulation on the arm64 build host. Fix: a new `ipxe-build`
Dockerfile stage pinned to $BUILDPLATFORM (native arch — no emulation)
that cross-compiles x86_64 iPXE with CROSS_COMPILE=x86_64-linux-gnu-.
The compiler runs native and emits x86_64. Validated end-to-end:
png.o + fbcon.o + pixbuf.o all compile and link (confirmed via the
linked-ELF symbol table, not just strings), ~112s, no segfault. Host
tools needed libc6-dev (dropped by --no-install-recommends; without
it the native host compile falls through to iPXE's freestanding
headers and dies on bits/stdint.h — fixed).

Server side:
- pxe_logo.rs is now a full-screen background compositor: a dark field
  (matching the WebUI theme) with the operator's uploaded logo across
  the top, or — with no upload — a default OpenPXE rainbow disc drawn
  with pure pixel math (no font/SVG deps). Always 1024x768 (iPXE
  doesn't scale; this is the universal mode). WebP/JPEG/GIF/PNG in,
  PNG out (iPXE only eats PNG).
- /branding/pxe-logo always returns a PNG now (default when no logo,
  default when SVG) so the menu always has a background.
- render_menu uses `console --picture … --top 290 || console`: paints
  the background and reserves the logo band on PNG-capable binaries
  (x86_64 UEFI), cleanly falls back to text on the others. The ASCII
  wordmark is GONE.

Only x86_64 UEFI is built from source (host-arch-agnostic cross build);
BIOS/i386/arm64 keep upstream-fetched no-PNG binaries + text fallback.
Modern clients are overwhelmingly x86_64 UEFI.

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

v0.4.68's privileged-port fix got past MNT3ERR_ACCES (mount); operators
then hit NFS3ERR_ACCES on READDIR because nfs3_client defaults to
AUTH_NONE and virtually every server exports sec=sys. We now present an
AUTH_UNIX credential (uid 0 / gid 0): no_root_squash servers treat us
as root, root_squash servers map us to anon which reads any
world-readable ISO share. Kept fixed (no UI knob) to stay dead-simple.
Hint updated: a remaining NFS3ERR_ACCES is now a server-side
permission/squash issue, not IP/auth-flavor.

## 3. FleetDM-style full-width logo (top-left)

When a custom logo is uploaded the sidebar header drops the bundled
mark + "OpenPXE" wordmark and lets the logo span the header
(left-aligned, capped 200x50, contain). Rendered server-side via a
brand-class in index_html (has_custom_logo) so there's no flash of the
default. The bundled-default case is unchanged.

Tests: 164 passing. clippy -D warnings clean. iPXE build stage
validated in isolation before the full image build.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 03:11:35 -04:00
Miles WardandClaude Opus 4.8 2f12a2ae84 v0.4.68: fix NFS secure-export mount, logo cache-bust, dashboard disk card, NFS form spacing
Four operator-reported issues from v0.4.67 validation.

## 1. NFS MNT3ERR_ACCES even with the host IP allow-listed

Root cause: Linux kernel nfsd (what UniFi UNAS / Synology / TrueNAS all
run underneath) exports with the `secure` option by default, which only
accepts mount/NFS requests from a privileged source port (<1024). v0.4.67
explicitly connected from a non-privileged port on the mistaken assumption
that uid 10001 can't bind low ports — but the binary carries
CAP_NET_BIND_SERVICE (granted via setcap for the DHCP/TFTP/HTTP low-port
binds), which also covers privileged *source* ports for outbound connects.

Fix: build_connection now tries a privileged source port first (the common
case for every appliance NAS), then falls back to a non-privileged port
for `insecure` exports or capability-less environments. Each attempt has
its own connect timeout; a timeout on the first attempt skips the fallback
(the server isn't answering — a retry would just double the wait).

Also: hint_for now recognizes MNT3ERR_ACCES distinctly from NFS3ERR_ACCES
and explains both the allow-list and the secure/insecure angle, with the
UniFi /var/nfs/shared/<share> path convention called out.

## 2. Custom logo didn't update the top-left brand mark

The brand <img> and favicon were pinned to ?v=<app-version>, which only
changes on upgrade — so uploading a new logo left the cached bundled SVG
in place. Added a monotonic `rev` counter to BrandingStore that bumps on
every set/clear, persisted across restarts, surfaced through index_html as
an extra &r=<rev> cache-bust token on the brand mark + favicon URLs. Since
index.html is served no-cache, the fresh token lands on the next reload
after upload and the new logo appears immediately.

(Note: this updates the WebUI brand mark. The PXE *boot menu* still shows
the ASCII wordmark — painting the operator's PNG there needs the
IMAGE_PNG-enabled iPXE rebuild that remains queued for native x86_64
hardware. The /branding/pxe-logo compositor is ready for when it lands.)

## 3. Disk-space card on the Dashboard

Extracted the Storage tab's disk card into a shared diskSpaceCard(disk)
helper and added it to the Dashboard grid under the stat strip. Dashboard
fetches /api/storage/disk with the same graceful-degradation fallback the
Storage tab uses.

## 4. NFS "Add share" button touching the form field

The NFS card has a single form row (vs SMB's two), so the button butted
right against it. Added margin-top:14px to match SMB's effective spacing.

Tests: 162 passing (+2 — logo_rev bump, MNT3ERR_ACCES hint). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-28 21:34:41 -04:00
Miles WardandClaude Opus 4.7 3f9d8568f0 v0.4.67: NFSv3 alongside SMB (in-process via nfs3_client crate)
NFS is back — done right this time. v0.4.67 ships a pure-Rust NFSv3
client (`nfs3_client` 0.9 from the xetdata/Vaiz crate family) running
in-process inside the openpxe binary. No `mount.nfs`, no kernel
modules, no `CAP_SYS_ADMIN`, no subprocess. Works in every container
that the v0.4.65 SMB path works in (Unraid included).

The v0.4.65 SMB path stays as-is. Operators get both protocols
side-by-side and pick whichever their NAS prefers — or use both
together. NFSv3 has one architectural advantage over the SMB
userspace path: HTTP Range requests work for NFS-sourced ISOs
because NFSv3 READ3 takes an explicit offset. SMB-sourced ISOs still
return 416 for ranges (smbclient CLI can't seek mid-stream).

## What's new

- `crates/iso-store/src/nfs_share.rs` — `NfsShareManager` mirroring
  `SmbShareManager` structurally. Lists ISOs via READDIR3+LOOKUP3+
  GETATTR3, streams files via READ3 in 64 KiB chunks piped to axum
  body streams. Uses `connect_from_privileged_port(false)` because
  the openpxe binary runs as uid 10001 — most modern NFS servers
  allow that; a server that demands privileged ports needs
  `insecure` in /etc/exports, and the hint translation calls that
  out specifically.
- `IsoSource::Nfs { share_id, relative_path }` variant alongside the
  existing `Smb`. `IsoStore::iso_path_for` returns None for both;
  the HTTP handler dispatches to the right share manager.
- `/api/nfs-shares` CRUD + scan endpoints, parallel to
  `/api/smb-shares`. `POST` body: `{ server, export, port? }`.
- `nfs` terminal command back (this time as in-process, not kernel
  mount): `list | add <srv>:<export> [port] | remove | scan`. The
  v0.4.64 `nfs` command name pointing at kernel mount is moot
  history — same name, completely different mechanism.
- Storage tab: a new NFS shares card sits directly below the SMB
  shares card. The form is simpler (no auth fields) since NFSv3
  uses AUTH_SYS and access is gated server-side by client IP.
- Dashboard "Images available" tile sums SMB + NFS reachable shares
  into a generic "N remote shares" line.

## What's the same

- The structured `{error, stderr, hint}` JSON shape on failures
  matches the SMB API exactly, so the UI's error banner renders
  identically.
- Hint translation: NFS3ERR_ACCES → "exports list", NFS3ERR_NOENT →
  "export path doesn't exist", `mount denied` → "/etc/exports may
  need `insecure`", timeouts → "check IP/port/firewall".
- Persistence: `<work_dir>/nfs_shares.json`. No conflict with the
  long-dead v0.4.64 `nfs.json`.

## Why nfs3_client

User picked it: pure-Rust matches the architecture, NFSv3 covers the
real-world cases, AUTH_SYS keeps the UI simple. The crate is at
0.9.0, MIT/Unlicense, rust-version 1.88 (we're on 1.95). Tokio
feature flag enabled. Image size unchanged at compile time — single
musl static binary, no extra OS packages.

## Tests

160 passing (was 150 in v0.4.66, +10):
- nfs_share parser: stable share ids, server normalization (smb://,
  cifs://, \\, // all stripped).
- hint_for(): NFS3ERR_ACCES, NFS3ERR_NOENT, mount denied, unknown.
- status_label() covers the common nfsstat3 codes.
- HTTP integration: nfs-shares list starts empty, missing server
  rejected, export without leading slash rejected.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 12:56:46 -04:00
Miles WardandClaude Opus 4.7 9f66c269c4 v0.4.66: ship smbclient in the runtime image
v0.4.65 added the SmbShareManager but the Dockerfile only installed
the `samba` package — in Debian 12 that ships the SERVER (smbd) only,
not the `smbclient` CLI the new manager shells out to. Every "Add
share" attempt surfaced:

    could not exec smbclient: No such file or directory (os error 2)

Fix is two lines: add `smbclient` to the runtime apt install, drop
the leftover `nfs-common` (no kernel-mount NFS anymore so the helpers
aren't needed).

While in the area, harden the manager so future stripped-down runtime
images get a useful error instead of a bare exec failure:

- `list_isos` and `stream_iso` both detect `ErrorKind::NotFound` on
  spawn and emit "smbclient binary not found on $PATH".
- `hint_for` translates the missing-binary pattern into an actionable
  hint: "pull OpenPXE v0.4.66+ or add the Debian `smbclient` package
  to your runtime stage." So even on a custom build the UI still
  surfaces a clear remediation.

Tests: 150 passing (+1 for the new hint). clippy clean.

The image is still ~98 MB — `smbclient` adds <1 MB on top of the
already-installed samba server.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:58:51 -04:00
Miles WardandClaude Opus 4.7 900b65b3ec v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)
v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.

What's gone:

* `crates/iso-store/src/nfs.rs` (in entirety)
* `NfsManager`, `NfsMount`, `NfsAddRequest`, `NfsVersion` types
* `IsoSource::Nfs` variant
* `IsoStore::nfs_root` / `IsoStore::set_nfs_root`
* `/api/nfs`, `/api/nfs/:id`, `/api/nfs/:id/scan` routes
* `nfs` terminal command
* Storage tab's NFS shares card and the v0.4.64 fstab-options
  diagnostics work (the whole error path is moot now)

What's new:

* `crates/iso-store/src/smb_share.rs` — `SmbShareManager` that drives
  `smbclient` as a subprocess. Indexes shares via `smbclient -c "ls
  *.iso"` and streams files via `smbclient -c "get file -"` piped
  straight into HTTP response bodies. No local cache, no double disk
  usage.
* `IsoSource::Smb { share_id, relative_path }` variant.
* `IsoStore::iso_path_for` returns None for SMB sources — the HTTP
  ISO download handler dispatches on the source kind and streams via
  the SmbShareManager when it's SMB.
* `/api/smb-shares` + `/api/smb-shares/:id` + `/api/smb-shares/:id/scan`
  routes.
* `share` terminal command (`list | add //srv/share [auth] | remove |
  scan`). Auth spec is `guest` or `user:password`.
* Storage tab: SMB shares card replaces the NFS one. Two-column form
  for server + share name, three-column form for guest checkbox /
  username / password. Username and password fields auto-disable when
  Guest is checked.
* Credentials live under <work_dir>/smb_creds/<id>.cred at 0600
  permissions so they don't leak through `ps`. Persisted state at
  <work_dir>/smb_shares.json (sans password — re-entered on add /
  re-scan).

Why subprocess and not a Rust crate:

* The Debian runtime image already ships the `samba` package
  (Dockerfile line 84) — `smbclient` is right there.
* Library options (pavao, etc.) wrap libsmbclient so they still pull
  in the same C library at runtime.
* Subprocess gives operators a verifiable mental model — anything
  OpenPXE can do over SMB, they can reproduce by running `smbclient`
  manually at a shell.

Range-request limitation, called out in the smb_share.rs module docs
and the UI explainer: `smbclient -c 'get file -'` is a sequential
whole-file stream. HTTP range requests on SMB-sourced ISOs return
416. PXE workloads (iPXE chain, casper sanboot, wimboot) do
whole-file sequential reads, so this works in practice. A follow-up
release can add libsmbclient-based seek if a real workload needs it.

Stderr-to-hint translation patterns mirror v0.4.64's NFS work:
NT_STATUS_LOGON_FAILURE → "check credentials", BAD_NETWORK_NAME →
"check share name", connection refused / timeout → "verify
reachability + firewall", etc. UI renders the raw smbclient error
plus the hint as two lines.

Tests (149 total, was 142 in v0.4.64):
* smb_share parser tests covering ISO + skipped directory, filenames
  with spaces, non-ISO filtering.
* hint_for() translation tests for the dominant NT_STATUS codes.
* Server normalization (smb://, cifs://, \\, // prefixes all stripped).
* HTTP integration: shares list starts empty, invalid server / missing
  username / path in share name all rejected with actionable hints.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:19:47 -04:00
Miles Ward 07e7c18698 Revert "v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)"
This reverts commit 72a2089c98.
2026-05-28 10:47:17 -04:00
Miles WardandClaude Opus 4.7 761489761c v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)
Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts
inside the OpenPXE container fail on Unraid with the same
"failed to apply fstab options" error v0.4.64 added diagnostics for.
The root cause is the host kernel: Unraid's base kernel ships without
the nfs/nfsv4 client modules loaded. Capabilities are necessary but
not sufficient; the modules have to be present on the host kernel for
in-container mount(2) to do anything. No container-side change can
fix that.

This is exactly the case every other PXE/imaging tool sidesteps
(Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host
to mount network storage and bind-mount the path into the imaging
service). v0.4.65 brings OpenPXE in line with that pattern.

What's new:

* `IsoSource::LocalDir { dir_id, relative_path }` — third source kind
  alongside `Local` (uploaded) and `Nfs` (in-container mount).
* `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers
  bind-mounted directories, validates them (absolute path, exists, is
  a directory, readable), scans for *.iso files, registers them with
  IsoStore. Persisted to <work_dir>/local_dirs.json so the relationship
  survives restarts.
* `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup.
  Surfaced via GET /api/nfs/capabilities and used by the Storage tab to
  show a prominent red banner above the NFS form when in-container
  mounts cannot possibly work, pointing the operator at the Local
  Directories card as the recommended path.
* Four new API routes:
    GET    /api/nfs/capabilities
    GET    /api/local-dirs
    POST   /api/local-dirs           { path, label? }
    DELETE /api/local-dirs/:id
    POST   /api/local-dirs/:id/scan

UI changes (crates/webui/src/app.js):
* Storage tab: new "Local directories" card under the NFS card with
  the bind-mount form, an explainer paragraph (with the Docker
  `-v /mnt/user/isos:/mnt/external-isos` command), and the list of
  registered directories with rescan + remove actions.
* When NFS host caps are unavailable, the NFS card sprouts a red
  banner explaining what's wrong and pointing at the local-dir
  workaround. The card sub-header also flips to "N registered ·
  recommended on this host".
* ISO table: new "dir:<id>" source badge; on-disk ISOs show "on disk"
  in the actions column instead of a delete button (same pattern as
  NFS — OpenPXE doesn't own those bytes).
* API reference table picks up the four new endpoints + a hint about
  the new `port` field on NFS add.

Tests (+12, total 162):
* iso-store: 7 local_dir unit tests covering relative-path rejection,
  missing path, non-directory file, empty-directory success, default
  label, idempotent re-add, remove + iso-path-resolution clear.
* iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never
  panics and the boolean accessors are consistent.
* http-api: 4 integration tests covering /api/nfs/capabilities,
  /api/local-dirs list/add/remove + relative-path 400.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 03:09:43 -04:00
Miles WardandClaude Opus 4.7 0afbe860e8 v0.4.64: NFS mount diagnostics — pre-flight probe, retry, hint translation
The dominant field failure from v0.4.63 was "mount.nfs: failed to apply
fstab options" (exit 32), surfaced verbatim by the Storage tab. The
message is misleading — it has nothing to do with /etc/fstab; it comes
from nfs-utils 2.6.x's nfs_options2string() and most commonly indicates
the container is missing CAP_SYS_ADMIN, /etc/mtab is unwritable, or an
auxiliary option triggered an option-transform edge case.

Backend (crates/iso-store/src/nfs.rs):
- TCP pre-flight probe to server:port (4s timeout) before shelling out.
  Catches wrong-IP / firewall cases as "cannot reach NFS port" instead
  of letting mount.nfs spit out an unhelpful message.
- proto=tcp explicit on NFSv3 (UDP is widely deprecated, modern NAS
  appliances often don't bind UDP at all).
- Optional `port` field on NfsAddRequest (defaults to 2049), persisted
  on NfsMount.
- On "failed to apply fstab options" / "internal option parsing error"
  retry with a minimal option set (vers=N,ro/rw only) — bypasses the
  nfs-utils transformation bug; if it still fails we get a real kernel
  error to translate.
- hint_for() translates well-known stderr patterns into actionable
  guidance — CAP_SYS_ADMIN for option-transform failures, exports-table
  for access-denied, export-path hint for "no such file or directory"
  (calling out the UniFi UNAS Pro /var/nfs/shared/<name> convention),
  etc.
- normalize_server() strips http://, https://, nfs:// schemes the
  operator may have pasted by mistake, plus trailing slashes.

API (crates/http-api/src/app.rs):
- api_nfs_add now returns a structured {error, stderr, hint} JSON body
  on failure instead of plain text. UI renders the error in bold with
  the hint as a dimmer second line.

UI (crates/webui/src/app.js):
- Storage tab's "Mount failed" banner now shows the raw error + hint on
  two lines. Each persisted mount row also surfaces last_hint under
  last_error.

Terminal (crates/http-api/src/terminal.rs):
- `nfs mount` command prints "hint: ..." on a follow-up line when the
  manager returns one.

Tests:
- 8 new tests covering option string (incl. proto=tcp on v3, port=N for
  non-default), minimal-options stripping, server normalization, and
  hint translation for each well-known stderr pattern.
- All 150 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 13:53:15 -04:00
Miles WardandClaude Opus 4.7 9f694f7c79 v0.4.63: SSO row alignment, themed checkbox, dropdown affordance
Three UI nits the operator caught on v0.4.62, plus the queued PXE-theme
research note for the next release.

- SSO header grid is now a 4-column form-row matching the Administrator
  account card column-for-column (display name / logo URL / metadata
  source / metadata URL). Switching to XML mode collapses column 4 and
  drops the multi-line textarea on its own full-width row below.
- Native form chrome (checkboxes, scroll bars) follows the active
  OpenPXE theme via CSS `color-scheme`; the inline meta tag was forcing
  dark form controls in light mode, which is why the "Enable single
  sign-on" checkbox rendered as an opaque black square against the
  light panel.
- Checkbox itself is now custom-styled (16x16 rounded square, accent
  fill + tick on :checked) so the chrome reads identically across both
  palettes and browsers, not just on whichever WebKit happens to honor
  `accent-color`.
- <select> dropdowns get a hand-drawn chevron via background-image SVG;
  with `-webkit-appearance: none` the native arrow had disappeared,
  making "Metadata source" look squished next to the inputs beside it.
- Update credentials + Save SSO settings buttons get explicit top
  margins so they sit clearly under their input rows instead of butting
  against the field beneath.
- `docs/queued/ipxe-pxe-menu-theme-research.md` captures findings on
  how iVentoy paints its boot menu (iPXE `console --picture` with
  baked-in per-resolution PNGs, no EDID auto-detect) and the
  recommended Rust architecture for the follow-up release.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 02:32:38 -04:00
Miles WardandClaude Opus 4.7 d729a7ae2f v0.4.62: ship the v0.4.61 cache fix as a buildable image
v0.4.61 source landed in main with the cache fix and the
PXE-logo compositor, plus an aspirational Dockerfile stage that
rebuilds iPXE from source with IMAGE_PNG enabled. The Dockerfile
stage hits intermittent `cc1: internal compiler error: Segmentation
fault` when cross-emulating x86_64 gcc under QEMU on arm64 build
hosts, which is what the build host I was using does. No v0.4.61
image was ever published as a result.

v0.4.62 walks back the iPXE-from-source change and ships a working
image with the same cache fix and the same compositor code in place.
The iPXE rebuild is queued for a follow-up release, to be built and
validated on the actual x86_64 Unraid hardware where the QEMU
instability doesn't apply.

What's in v0.4.62 vs v0.4.6:

- Asset URL versioning: index.html now appends `?v=<openpxe-version>`
  to every asset URL (app.css, app.js, logo.svg). Combined with
  `Cache-Control: no-cache, must-revalidate` on the asset handlers,
  upgrades land in operators' browsers without a hard refresh. This
  is the fix for "I pulled v0.4.6 but the UI still looks like v0.4.5".
- New PXE-logo compositor in iso-store::pxe_logo: decodes any raster
  the operator uploads, scales-to-fit into a 600×200 bounding box,
  pastes it centered at the top of a 1024×768 PNG canvas, and serves
  the result at GET /branding/pxe-logo. Wired into render_menu's
  `console --picture` directive; takes effect when the shipped iPXE
  binaries grow PNG support.
- ASCII OpenPXE wordmark in render_menu retained for v0.4.62 — works
  on the boot.ipxe.org pre-builds we currently ship.

Quality:
- 142 tests passing.
- cargo clippy --workspace --all-targets clean.
- No image dependency change since v0.4.61 (the `image = "0.25"` dep
  added in v0.4.61 stays — it backs the compositor).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:53:30 -04:00
Miles WardandClaude Opus 4.7 1419309a2d v0.4.61: asset cache fix, PNG-enabled iPXE, composed PXE logo
Two real issues v0.4.6 left on the table:

Asset caching:
- index.html now interpolates the running OpenPXE version into every
  asset URL as `?v=<version>` (app.css, app.js, logo.svg). Combined
  with `Cache-Control: no-cache, must-revalidate` on the asset
  handlers, browsers and intermediary proxies are forced to fetch
  fresh on every upgrade. Without this, last release's bundled JS
  kept serving the old UI even after the operator pulled the new
  image — invisible to anyone who only checks the version chip in
  the footer (which is dynamic).
- The Cache-Control header is also applied to logo.svg and loader.svg
  so a logo upload reflects immediately rather than after a hard
  refresh.

Real-image PXE menu logo (matches iVentoy now):
- New Dockerfile stage `ipxe-build` clones the iPXE source and
  compiles all four binaries (undionly.kpxe, snponly.efi for
  x86_64/i386, snponly.efi for arm64 via gcc-aarch64-linux-gnu) with
  IMAGE_PNG + CONSOLE_FRAMEBUFFER + CONSOLE_VESAFB enabled. Replaces
  the boot.ipxe.org fetch — those binaries are built without PNG
  support, which is why v0.4.6's `console --picture` line silently
  no-op'd.
- `iso-store::pxe_logo::compose_pxe_logo` decodes any operator upload
  (PNG / JPEG / WebP / GIF), downscales-to-fit if larger than
  600×200, and pastes it onto a transparent 1024×768 canvas
  centered horizontally with a 64-pixel top margin. iPXE paints the
  result at 1:1 on the typical VESA framebuffer, giving the
  iVentoy-style centered-logo look regardless of the operator's
  source dimensions.
- GET /branding/pxe-logo now returns the composed PNG. wimboot still
  fetches from ipxe/wimboot's GitHub release (separately signed).
- Dropped the ASCII OpenPXE wordmark from render_menu — once the
  real image paints, the banner would duplicate it visually. iPXE
  builds without PNG (none of ours after this release, but a third-
  party undionly might) simply show the menu without a logo, which
  is the right graceful-degradation outcome.

Quality:
- 142 tests passing (was 138 in v0.4.6): +4 pxe_logo unit tests
  covering canvas dimensions, centered-top placement, oversize
  downscale, and unsupported-bytes error handling; existing
  integration tests updated to verify the 1024×768 IHDR header from
  the composed PNG instead of round-tripping the raw upload.
- cargo clippy --workspace --all-targets clean.
- Image dependency: `image = "0.25"` with only `png/jpeg/webp/gif`
  features enabled. No new transitive C deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:38:39 -04:00
Miles WardandClaude Opus 4.7 55f4765a20 v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
  <base>/branding/pxe-logo || console` line so iPXE builds with PNG
  support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
  `item --gap` lines — always visible on every iPXE build, including
  the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
  where <arch label> is mapped from iPXE's ${buildarch}/${platform}
  to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
  WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
  can't rasterize SVG) — the always-visible ASCII wordmark stands in.
  Route stays public after admin setup so iPXE clients (no cookies)
  can fetch it.

UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
  Click opens a small popover with: Name (display only), Edit account
  (jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
  `label.field` selector only styled type=text/number, leaving
  password inputs with default browser chrome. Switched to a
  negation-list selector that covers every typed input we use, plus
  -webkit-appearance:none + a 1px focus ring. Light + dark mode both
  show the same border/padding/focus state across all four account
  fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
  and metadata source on one 3-column row. The metadata <select>
  inherits the same chrome as the text inputs so it baseline-aligns
  with them. SsoConfig grew an idp_logo_url field, persisted to
  sso.json, length-capped and validated to http(s) only.

Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
  +1 PXE menu polish regression guard, +4 /branding/pxe-logo
  integration tests covering missing-config / SVG-fallback / raster-
  serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:02:20 -04:00
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
Miles WardandClaude Opus 4.7 7b972dc049 v0.4.4: Settings tab, API reference, ISO category, branding, disk space
Settings:
- New top-level Settings tab. Carries a placeholder for the planned
  LDAP / OIDC / user-management work, the new branding controls, and
  the API reference at the bottom.
- Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the
  bundled brand mark via /assets/logo.svg; bytes live at
  <work_dir>/branding/ and survive restart. The original "OpenPXE
  v<x.y.z>" pins to the sidebar footer for support.
- API reference rendered from a new GET /api/docs into a per-method
  coloured pill list grouped by area.

ISO category (Storage):
- New IsoCategory { Os, Tools } on IsoMeta with PUT
  /api/isos/:id/category. Storage table's Type cell becomes a
  dropdown; selecting Tools moves the ISO into the Tools submenu next
  to memtest / shell / NIC info and removes it from the OS Installers
  family submenu. Family detection still drives BIOS/UEFI / kernel
  args; only the menu placement changes.

Storage telemetry:
- New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the
  http-api crate stays #![forbid(unsafe_code)]) and GET
  /api/storage/disk. The Storage tab now shows free/used/total for
  the volume hosting the ISO directory with an 80%/95% colour ramp.

UI polish:
- Brand block in the sidebar now matches the topbar height exactly,
  so the divider runs straight across the top of the app rather than
  stepping; version label moved out of the brand and pinned to the
  sidebar footer ("OpenPXE v0.4.4").
- Light-mode terminal: --terminal-bg + per-level text colours track
  the active theme rather than being hard-coded dark.
- About: lead paragraph spans the full content width; new Docs row
  links to https://openpxe.com/.

106 tests passing (was 89 in v0.4.1, +17 across branding unit tests
and new integration coverage for category / disk / docs / branding).
cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-25 17:46:52 -04:00
Miles Ward a171331a7a make container builds reproducible
Commit Cargo.lock, copy it into the Docker build stage, and align the Docker Rust base/MSRV with the toolchain required by the locked dependency graph.
2026-05-24 13:53:10 -04:00
Miles Ward fe4a127422 fix docker build toolchain selection
Do not copy rust-toolchain.toml into the Docker build stage so the release image uses the Rust toolchain provided by the base image instead of downloading latest stable inside the container.
2026-05-24 13:49:09 -04:00
Miles Ward 2c1c80a7ca v0.4.1: harden ISO uploads and beta UI polish
Add browser-safe chunked ISO uploads with progress, partial-file visibility, offset validation, and abort cleanup while keeping the legacy multipart endpoint for API clients.

Record host-log validation coverage, keep the queue/status UI copy clean, move release docs to 0.4.1, and tighten the dark theme to a near-black Netbox-style palette.
2026-05-24 13:45:35 -04:00
Miles WardandClaude Opus 4.7 ec171ede47 v0.4.0: upload telemetry, host log, jet-black UI
- Upload reliability + diagnostics:
  - api_upload_iso now distinguishes clean EOF from mid-stream errors;
    a truncated multipart body (proxy buffer cap, network drop) returns
    400 with the cause and a "try the LAN IP" hint instead of silently
    finalising a partial file.
  - Per-stage tracing (begin/MB-watermark/finish/abort) so a stuck
    upload is debuggable from the Terminal tab.
  - Web upload UI surfaces bytes/total, percent, throughput, ETA, and
    maps 413/502/504/network-drop to actionable hints.
- New BootLog feature under Hosts:
  - openpxe-core::BootLog — bounded in-memory ring (500) + append-only
    JSONL on disk, recording (timestamp, mac, ip, target_id,
    target_title) every time a boot entry script is served.
  - iPXE per-entry chain URLs grow ?mac=${mac}; password prompt
    submission carries it through; host-binding short-circuit uses the
    bound MAC. ConnectInfo<SocketAddr> wired for peer IP capture (with
    optional fallback so tower::oneshot in tests still works).
  - GET /api/boot-log endpoint + Host log table under the Hosts tab.
- UI changes:
  - Queue card header "Forge" → "Status".
  - Removed Tinkerbell attribution sentence from Hosts tab.
  - Topbar readiness chip moved into the sidebar footer as
    "Service status: Ready / Advertised to clients / <url>", grouping
    advertised PXE URL with operator-relevant status.
  - Jet-black dark palette (#000 / #0a0a0a / #141414 / #1c1c1c)
    replacing the blue-tinted ramp; terminal toolbar/input recoloured
    to match.
- 89 tests passing (was 85 in v0.3.2); cargo clippy --workspace
  --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 13:10:40 -04:00
Miles Ward 115ba779da Name update 2026-05-21 02:13:08 -04:00
Miles Ward 91848e02e3 v0.3.1: per-ISO boot password gate
Operators can now lock individual ISOs behind a password set in the
WebUI. Picking a locked image at the PXE menu prompts the operator on
the client console; the boot script is only released after a correct
match. The plaintext never leaves the request — server stores bcrypt
hashes, scripts never echo the candidate.

## Backend

- New optional `password_hash: Option<String>` on `IsoMeta`. Skipped
  during serialize when None, so existing meta.json files don't grow
  a noisy `null` field.
- `IsoStore::set_password(id, Some("pw"))` hashes via bcrypt
  `DEFAULT_COST` (10 — fast enough for an interactive iPXE prompt,
  expensive enough to be hostile to brute force on a leaked
  meta.json). `set_password(id, None)` and `set_password(id, Some(""))`
  both clear.
- `IsoStore::verify_password` returns Ok(true) when no password is
  set, so the gate stays open for the common case.
- `IsoMeta::is_password_protected()` predicate the HTTP layer + UI
  share.
- NFS-sourced ISOs persist their hash in memory only — the share is
  the source of truth for those, and it doesn't carry hash sidecars.

## HTTP API

- `PUT /api/isos/:id/password` body `{ "password": "..." }` to set,
  `{ "password": null }` (or empty string) to clear.
- `DELETE /api/isos/:id/password` for the explicit clear.
- Both 204 on success, 404 for unknown ids.
- `/boot/<entry>.ipxe` now intercepts:
  - no `?token=`        -> render password-prompt script
  - `?token=<wrong>`    -> render auth-fail script (sleeps 2s, chains
                           back to the entry which re-prompts)
  - `?token=<correct>`  -> render the real boot script
  - ISO without password ignores token entirely (per-MAC bookmarks
    still work without changes).

## iPXE prompt

`render_password_prompt`:
- `set password ` then `read --secret password` — accepts input
  without echoing.
- Empty input chains back to the main menu (lets the operator back
  out of a misclick).
- Submit chains `?token=${password:uristring}`. The `:uristring`
  modifier URL-encodes the value, so passwords with `&`, `?`, `=`,
  spaces, etc. survive transport.

`render_password_failed`:
- Single line saying so + 2s sleep, then re-chains the entry.
- Server-side WARN log records the entry id only, never the
  candidate value (verified in smoke test).

## UI

Storage tab's image table grows an `Auth` column showing
`protected` / `open`, plus a 🔒 next to the filename when locked.
Per-row "Set password" / "Password ✎" button toggles an inline
editor in the next table row containing:
- a "Password protect this image" checkbox
- a `<input type=password autocomplete=new-password>` (hidden when
  the checkbox is off)
- a Save button

Save calls PUT or DELETE on `/api/isos/:id/password` based on the
checkbox state and clears the input field before re-rendering, so
the plaintext doesn't sit in the DOM longer than needed.

## Menu indicator

`render_family_menu` adds a `*` prefix immediately before the size
box on protected entries — ASCII only because some firmware menu
consoles mangle non-ASCII glyphs. Looks like:

  item --key 1 win11_test-winpe *[ 5234 MB] Windows 11 Test ISO

## Tests

74 passing across the workspace (was 66 in v0.3.0):
- 3 new store unit tests (bcrypt round-trip, unknown-id error,
  meta.json persistence across restart)
- 2 new ipxe_script unit tests (prompt/auth-fail invariants:
  read --secret, uristring, no candidate echo)
- 3 new HTTP integration tests (full gate flow upload-set-prompt-
  fail-success-clear, null/empty bodies, 404 on unknown id)

cargo clippy --workspace --all-targets clean.

Local smoke verified upload + lock + prompt + auth-fail + correct +
menu indicator + log scrub on a real release binary.

## Operational notes

- HTTP, not HTTPS — token rides in the query string. Acceptable on
  a trusted boot VLAN; do NOT expose OpenPXE to untrusted networks
  with this feature relied on for security. Reverse-proxy in front
  of OpenPXE will end up with the token in access logs.
- bcrypt cost is `DEFAULT_COST` (10). One verify takes ~50ms on
  modern x86, which is the worst-case latency added to a correct
  boot. Tunable via the bcrypt crate if needed.
2026-05-06 22:35:11 -04:00
Miles Ward 4e88305101 Name update 2026-05-06 14:14:09 -04:00
Miles Ward 20c585e3ed Name update 2026-05-06 14:13:38 -04:00
503432756 e1b7154b51 docs: add Linux network-boot runbook 2026-04-30 11:35:47 -04:00
Miles Ward 40064906de Name update 2026-04-30 02:30:05 -04:00
Miles Ward 49d0b00a8a v0.2.0 — pre-beta: per-MAC bindings, /metrics, themes, animated forge
This is the bulk pre-beta cleanup pass. Bumps the workspace to 0.2.0.
Test count is 56 -> 66 (+10), clippy is fully clean across the
workspace (was several dozen warnings).

## New features

**Per-MAC host bindings** (Tinkerbell smee pattern). New
`HostBindings` registry maps a MAC -> preferred boot target, persisted
to <work_dir>/hosts.json. The DHCP reply now embeds `?mac=${mac}` in
the boot.ipxe URL; iPXE substitutes the literal MAC client-side, so
the HTTP layer can short-circuit straight to the bound target instead
of rendering the menu. Reserved menu shortcuts (`_local`, `_gate`,
`_tools_menu`) are valid targets too. New /api/hosts CRUD + a Hosts
tab in the sidebar.

**Prometheus `/metrics`** endpoint. Tiny lock-free implementation —
just AtomicU64s and a Display impl, no `prometheus` / `metrics-rs`
dep. Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status), TFTP bytes, HTTP requests (per route).
Gauges: ISO count, client count, gate count, gate-imaging, NFS active
mounts, uptime, build info. Plain text exposition format,
text/plain;version=0.0.4 content-type, no auth (all metric values are
non-sensitive counts).

**Light + dark themes**. CSS tokens on `:root` and
`:root[data-theme=light]`, swap by toggle button (top-right) or `T`
hotkey. Persisted in localStorage; pre-paint inline script avoids
dark<->light flash. Light palette designed against the Netbox Labs
reference screenshot — near-white surfaces, soft grey dividers,
accent unchanged for brand consistency. Terminal pane stays dark in
both themes (it's a console, that's the right read).

**Animated SVG logo + forge widget**. New `logo.svg` is a refined
silver/grey anvil. New `anvil-forge.svg` adds rising sparks and a
pulsing underglow via SMIL — pure SVG, no GIF, no JS animation loop.
Used:
  - in the **forge progress** widget on Dashboard + Forge Gate, paired
    with a `linear-gradient(warn -> accent)` bar with a moving sheen;
    goes idle (greyscale, no sheen) at zero imaging load
  - in the page-load `<div class=loader>` that replaces the old
    "Loading..." text

## Code cleanup pass

`cargo clippy --workspace --all-targets` is now warning-free. Spot
fixes across the tree:
  - `format!()`-into-`String` -> `std::fmt::Write::write!`
  - manual reverse comparators -> `Reverse`
  - `map_or(false, ...)` -> `is_some_and`
  - redundant closures -> method references
  - `r#"..."#` raw strings without `"` -> `r"..."`
  - `std::io::Error::new(Other, ...)` -> `Error::other`
  - `as i32` on `c.id()` -> `cast_signed()`
  - merged identical match arms

## Windows workflow validation

New integration test synthesizes an ISO9660 with the SOURCES\\BOOT.WIM
sentinel, uploads it, asserts:
  1. introspection labels it `windows_pe` with has_boot_wim=true,
  2. the boot entry is `BootKind::Wimboot` with all five canonical
     files (bootmgr, bootmgr.efi, bcd, boot.sdi, boot.wim),
  3. the rendered iPXE script chains wimboot with `initrd --name`
     entries for each file, and
  4. NO trust-store strings appear in the rendered output: bcdedit,
     testsigning, certutil, httpdisk, and test-signed are all
     explicitly forbidden as a hard guarantee.

WinPE bootstrap (startnet.cmd) picks up the Bootimus v0.1.58 lessons:
explicit `net start Workstation` before `net use` to avoid the SMB
client lazy-init race, and surfaces errors instead of blind retries.

## Docs

architecture.md gains a "Phase 5" section explaining the host-bindings
+ metrics + theming + Windows-test work, plus a refreshed "deferred
to Phase 6" list (real-hardware integration, autounattend library,
distro profile manifest, WoL trigger, syslog receiver, IPv6).
README updates the status line, the "what it does" list, and adds
the new Hosts/Terminal tab names.
2026-04-30 02:28:10 -04:00
Miles Ward a9c4f408a9 Name update 2026-04-30 00:02:29 -04:00
Miles Ward 3517c67831 Name update 2026-04-29 02:47:00 -04:00
25 changed files with 316 additions and 5741 deletions
-16
View File
@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
Generated
+36 -2025
View File
File diff suppressed because it is too large Load Diff
+2 -21
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.5.1"
version = "0.4.69"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
mime = "0.3"
mime_guess = "2.0"
@@ -66,25 +66,6 @@ rust-embed = { version = "8.5", features = ["include-exclude"] }
nfs3_client = { version = "0.9", features = ["tokio"] }
nfs3_types = "0.5"
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
# to match reqwest and stay musl-static-friendly — no OpenSSL.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
# zlib/zlib-ng C backends, which would break the musl-static build.
flate2 = "1.1"
base64 = "0.22"
openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" }
-14
View File
@@ -25,19 +25,5 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
# encode the HTTP-Redirect binding's SAMLRequest.
bergshamra.workspace = true
roxmltree.workspace = true
quick-xml.workspace = true
x509-parser.workspace = true
flate2.workspace = true
base64.workspace = true
[dev-dependencies]
tempfile = "3.12"
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+1 -6
View File
@@ -12,25 +12,20 @@ pub mod error;
pub mod host_bindings;
pub mod log_bus;
pub mod metrics;
pub mod notify;
pub mod queue;
pub mod saml;
pub mod settings;
pub mod sso;
pub mod wol;
pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoStore};
-362
View File
@@ -1,362 +0,0 @@
//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;
/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
/// Slack incoming webhook (`{ "text": ... }`).
#[default]
Slack,
/// Discord webhook (`{ "content": ... }`).
Discord,
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
Teams,
/// Email via SMTP.
Smtp,
}
impl NotifyKind {
/// True when this kind drives a chat webhook (POST a JSON body to a
/// single URL) rather than SMTP.
#[must_use]
pub fn is_webhook(self) -> bool {
matches!(self, Self::Slack | Self::Discord | Self::Teams)
}
}
/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub kind: NotifyKind,
/// Incoming-webhook URL for Slack / Discord / Teams.
#[serde(default)]
pub webhook_url: String,
// ── SMTP fields (used when kind == Smtp) ──
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
/// `From:` address. Falls back to `smtp_username` when blank.
#[serde(default)]
pub smtp_from: String,
/// `To:` address (single recipient — keep it simple).
#[serde(default)]
pub smtp_to: String,
/// Use implicit TLS (port 465). When false we use STARTTLS on the
/// configured port (587 typical). Either way the connection is
/// encrypted — we never offer plaintext SMTP.
#[serde(default)]
pub smtp_implicit_tls: bool,
}
fn default_smtp_port() -> u16 {
587
}
impl NotifyConfig {
/// True when enabled and the active provider has the fields it
/// needs to actually send.
#[must_use]
pub fn is_usable(&self) -> bool {
if !self.enabled {
return false;
}
if self.kind.is_webhook() {
!self.webhook_url.trim().is_empty()
} else {
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
}
}
/// A copy safe to return over the API: the SMTP password is blanked
/// (replaced with a non-empty sentinel only when one is set, so the
/// UI can show "configured" without leaking it).
#[must_use]
pub fn redacted(&self) -> NotifyConfig {
let mut c = self.clone();
if !c.smtp_password.is_empty() {
c.smtp_password = SECRET_SENTINEL.to_string();
}
c
}
}
/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";
/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<NotifyConfig>>,
}
impl NotifyStore {
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("notify.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openpxe::notify",
"notify.json unreadable ({e}); starting with defaults"
);
NotifyConfig::default()
}),
Err(_) => NotifyConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> NotifyConfig {
self.inner.read().clone()
}
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
/// is treated as "keep the existing password" so the UI never has to
/// round-trip the real secret.
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
incoming.smtp_host = incoming.smtp_host.trim().to_string();
incoming.smtp_username = incoming.smtp_username.trim().to_string();
incoming.smtp_from = incoming.smtp_from.trim().to_string();
incoming.smtp_to = incoming.smtp_to.trim().to_string();
// Preserve the stored password when the UI sends the sentinel.
if incoming.smtp_password == SECRET_SENTINEL {
incoming
.smtp_password
.clone_from(&self.inner.read().smtp_password);
}
// Length caps.
if incoming.webhook_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"webhook URL exceeds {MAX_URL_LEN}-char cap"
)));
}
for (name, v) in [
("smtp_host", &incoming.smtp_host),
("smtp_username", &incoming.smtp_username),
("smtp_from", &incoming.smtp_from),
("smtp_to", &incoming.smtp_to),
] {
if v.len() > MAX_FIELD_LEN {
return Err(Error::Invalid(format!(
"{name} exceeds {MAX_FIELD_LEN}-char cap"
)));
}
}
// Validate the active provider only when enabling.
if incoming.enabled {
if incoming.kind.is_webhook() {
if incoming.webhook_url.is_empty() {
return Err(Error::Invalid(
"a webhook URL is required to enable chat notifications".into(),
));
}
if !incoming.webhook_url.starts_with("https://")
&& !incoming.webhook_url.starts_with("http://")
{
return Err(Error::Invalid(
"webhook URL must start with http:// or https://".into(),
));
}
} else {
if incoming.smtp_host.is_empty() {
return Err(Error::Invalid(
"SMTP host is required to enable email notifications".into(),
));
}
if incoming.smtp_to.is_empty() {
return Err(Error::Invalid(
"a recipient (To) is required to enable email notifications".into(),
));
}
if incoming.smtp_port == 0 {
return Err(Error::Invalid("SMTP port must be non-zero".into()));
}
}
}
{
let mut g = self.inner.write();
*g = incoming.clone();
}
self.persist();
tracing::info!(
target: "openpxe::notify",
enabled = incoming.enabled, kind = ?incoming.kind,
"notification configuration updated"
);
Ok(incoming)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_disabled_not_usable() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
assert!(!s.snapshot().enabled);
assert!(!s.snapshot().is_usable());
}
#[test]
fn slack_requires_url_when_enabled() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
webhook_url: "https://hooks.slack.com/services/XXX".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn smtp_requires_host_and_recipient() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_from: "[email protected]".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn password_sentinel_preserves_stored_secret() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: "s3cret".into(),
..Default::default()
})
.unwrap();
// Redacted snapshot hides the password behind the sentinel.
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
// PUTting the sentinel back keeps the real password.
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: SECRET_SENTINEL.into(),
..Default::default()
})
.unwrap();
assert_eq!(s.snapshot().smtp_password, "s3cret");
}
#[test]
fn webhook_url_scheme_enforced() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Discord,
webhook_url: "ftp://example.com/hook".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
-188
View File
@@ -1,188 +0,0 @@
//! AuthnRequest construction + HTTP-Redirect binding encoding.
//!
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _;
use base64::Engine;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{SamlError, SpParams};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// A built AuthnRequest, ready to redirect the browser to the IdP.
#[derive(Debug, Clone)]
pub struct AuthnRequest {
/// The request `ID` — the caller records this so the matching response's
/// `InResponseTo` can be correlated (replay/CSRF protection).
pub id: String,
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
/// when supplied, `RelayState`).
pub location: String,
}
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
/// the response (we use it to send the operator to their intended page).
pub fn build(
sp: &SpParams,
idp_sso_url: &str,
relay_state: Option<&str>,
) -> Result<AuthnRequest, SamlError> {
let id = format!("_{}", uuid::Uuid::new_v4().simple());
let issue_instant = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Rfc3339)
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
let xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
instant = issue_instant,
dest = xml_escape(idp_sso_url),
acs = xml_escape(&sp.acs_url),
issuer = xml_escape(&sp.entity_id),
);
let encoded = deflate_base64(&xml)?;
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
if let Some(rs) = relay_state {
location.push_str("&RelayState=");
location.push_str(&pct_encode(rs));
}
Ok(AuthnRequest { id, location })
}
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(xml.as_bytes())
.and_then(|()| enc.try_finish())
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
let compressed = enc
.finish()
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
}
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
/// through; everything else is `%XX`).
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::DeflateDecoder;
use std::io::Read;
fn sp() -> SpParams {
SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
}
}
fn pct_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[test]
fn id_is_ncname_and_location_has_request() {
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
assert!(req.id.starts_with('_'));
assert!(req
.location
.starts_with("https://idp.example.com/sso?SAMLRequest="));
assert!(req.location.contains("&RelayState=%2Fdashboard"));
}
#[test]
fn redirect_payload_round_trips_to_our_authn_request() {
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
// Pull SAMLRequest value out of the query string.
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
let val = q.split('&').next().unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(pct_decode(val))
.unwrap();
let mut inflate = DeflateDecoder::new(&compressed[..]);
let mut xml = String::new();
inflate.read_to_string(&mut xml).unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID").unwrap(), req.id);
assert_eq!(
root.attribute("AssertionConsumerServiceURL").unwrap(),
"https://pxe.example.com/api/sso/acs"
);
let issuer = root
.descendants()
.find(|n| n.tag_name().name() == "Issuer")
.unwrap();
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
}
#[test]
fn existing_query_uses_ampersand_separator() {
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
assert!(req.location.contains("?foo=bar&SAMLRequest="));
}
}
-241
View File
@@ -1,241 +0,0 @@
//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.
use base64::Engine;
use super::{SamlError, SpParams};
/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
pub entity_id: String,
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
pub sso_redirect_url: Option<String>,
/// SSO endpoint for the HTTP-POST binding (fallback target).
pub sso_post_url: Option<String>,
/// DER-encoded X.509 signing certificate(s). More than one appears during
/// key rotation; verification tries each.
pub signing_certs_der: Vec<Vec<u8>>,
}
impl IdpMetadata {
/// Parse an IdP `EntityDescriptor` document.
///
/// Robust to namespace-prefix variation (matches on local element names),
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
pub fn parse(xml: &str) -> Result<Self, SamlError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
// The signing IDP descriptor. Some metadata wraps multiple
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
let idp_desc = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
// IDPSSODescriptor when several are nested).
let entity_id = idp_desc
.ancestors()
.find_map(|n| {
if n.tag_name().name() == "EntityDescriptor" {
n.attribute("entityID")
} else {
None
}
})
.or_else(|| root.attribute("entityID"))
.map(str::to_owned)
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
let mut sso_redirect_url = None;
let mut sso_post_url = None;
for sso in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
{
let binding = sso.attribute("Binding").unwrap_or("");
let location = sso.attribute("Location").map(str::to_owned);
match binding {
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
_ => {}
}
}
// Signing certs: KeyDescriptor with use="signing" or no use attribute
// (a bare KeyDescriptor is valid for both signing and encryption).
let mut signing_certs_der = Vec::new();
for kd in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
{
match kd.attribute("use") {
Some("signing") | None => {}
Some(_) => continue, // encryption-only key — skip
}
for cert_node in kd
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
{
let b64: String = node_text(&cert_node)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if b64.is_empty() {
continue;
}
let der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| SamlError::Base64(e.to_string()))?;
signing_certs_der.push(der);
}
}
if signing_certs_der.is_empty() {
return Err(SamlError::NoSigningCert);
}
Ok(Self {
entity_id,
sso_redirect_url,
sso_post_url,
signing_certs_der,
})
}
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
/// if advertised, otherwise HTTP-POST.
pub fn sso_destination(&self) -> Option<&str> {
self.sso_redirect_url
.as_deref()
.or(self.sso_post_url.as_deref())
}
}
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
let entity = xml_escape(&sp.entity_id);
let acs = xml_escape(&sp.acs_url);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
</SPSSODescriptor>
</EntityDescriptor>
"#
)
}
/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
n.children()
.filter(roxmltree::Node::is_text)
.filter_map(|c| c.text())
.collect()
}
/// Minimal XML attribute/text escaping for the values we interpolate.
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
// signing tests build real certs in the parent module's tests).
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/realms/fleet">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
QUJDREVG
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#;
#[test]
fn parses_entity_sso_and_signing_cert() {
let m = IdpMetadata::parse(SAMPLE).unwrap();
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
assert_eq!(
m.sso_redirect_url.as_deref(),
Some("https://idp.example.com/realms/fleet/protocol/saml")
);
assert!(m.sso_post_url.is_some());
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
// encryption one (ZZZZ).
assert_eq!(m.signing_certs_der.len(), 1);
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
}
#[test]
fn missing_signing_cert_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
<IDPSSODescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
</IDPSSODescriptor></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::NoSigningCert)
));
}
#[test]
fn missing_idp_descriptor_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::Metadata(_))
));
}
#[test]
fn sp_metadata_contains_entity_and_acs() {
let sp = SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
};
let xml = build_sp_metadata(&sp);
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
assert!(xml.contains(BINDING_POST));
// Must be well-formed.
roxmltree::Document::parse(&xml).unwrap();
}
}
-92
View File
@@ -1,92 +0,0 @@
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
//!
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
//!
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
//! certificates) and build *our* SP metadata for the IdP admin to import.
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
//! HTTP-Redirect binding.
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
//! Audience, time bounds) that are where SAML SPs actually get attacked.
//!
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
//! against requests *we* issued, gating IdP-initiated login) live in the
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
//! the IDs the caller needs to perform them.
//!
//! Access model: any assertion the IdP authenticates and we cryptographically
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
//! there is no per-user role table — and the local admin account remains a
//! guaranteed fallback owner regardless of SSO state.
pub mod authn_request;
pub mod metadata;
pub mod response;
pub use authn_request::AuthnRequest;
pub use metadata::IdpMetadata;
pub use response::{VerifiedPrincipal, VerifiedResponse};
use thiserror::Error;
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
/// (Shibboleth/FleetDM defaults are in this ballpark).
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
/// public base URL by the HTTP layer.
#[derive(Debug, Clone)]
pub struct SpParams {
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
/// in responses). Defaults to the public base URL when the operator left
/// the Entity ID field blank.
pub entity_id: String,
/// The Assertion Consumer Service URL the IdP POSTs the response to —
/// `<public_base_url>/api/sso/acs`.
pub acs_url: String,
}
/// Everything that can go wrong consuming a SAML response. Kept coarse on
/// purpose: the HTTP layer logs the detail and shows the operator a generic
/// "SSO sign-in failed" — we never leak which specific check tripped to the
/// browser, since that aids an attacker probing the SP.
#[derive(Debug, Error)]
pub enum SamlError {
#[error("SAML XML parse error: {0}")]
Xml(String),
#[error("IdP metadata is missing a required element: {0}")]
Metadata(String),
#[error("no usable IdP signing certificate in metadata")]
NoSigningCert,
#[error("signature verification failed: {0}")]
Signature(String),
#[error("the signature does not cover the assertion we read")]
SignatureScope,
#[error("SAML response status was not Success: {0}")]
Status(String),
#[error("response is missing a required element: {0}")]
MissingElement(String),
#[error("encrypted assertions are not supported in this release")]
EncryptedAssertionUnsupported,
#[error("expected exactly one assertion, found {0}")]
AssertionCount(usize),
#[error("issuer mismatch: response was not issued by the configured IdP")]
IssuerMismatch,
#[error("audience mismatch: assertion is not addressed to this service provider")]
AudienceMismatch,
#[error("response destination does not match our ACS URL")]
DestinationMismatch,
#[error("assertion is expired or not yet valid")]
TimeBounds,
#[error("invalid SAML timestamp: {0}")]
Timestamp(String),
#[error("base64 decode failed: {0}")]
Base64(String),
}
#[cfg(test)]
mod tests;
-294
View File
@@ -1,294 +0,0 @@
//! SAMLResponse consumption: signature verification + SP-side validation.
//!
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
//! against the IdP's pinned certificate(s) and enforces every check that can
//! be made from the response alone (Status, Destination, Issuer, Audience,
//! time bounds, signature scope). It then returns the `assertion_id` and
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
//! owns: replay rejection, correlating the request we issued, and gating
//! IdP-initiated login.
use roxmltree::{Document, Node};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{SamlError, SpParams};
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
/// is all an operator session needs.
#[derive(Debug, Clone)]
pub struct VerifiedPrincipal {
/// The `<NameID>` value (an email, per our requested NameID format).
pub name_id: String,
/// Email used as the session identity. Equals `name_id` for the
/// emailAddress NameID format.
pub email: String,
/// Human-readable display name, if the IdP sent one as an attribute.
pub display_name: Option<String>,
}
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
/// stateful checks.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub principal: VerifiedPrincipal,
/// `InResponseTo` from the response, if present. `None` = unsolicited
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
/// enabled it.
pub in_response_to: Option<String>,
/// The assertion's `ID` — used by the caller as the replay-guard key.
pub assertion_id: String,
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
/// guard can drop the consumed ID after this instant.
pub assertion_expiry: OffsetDateTime,
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
pub session_index: Option<String>,
}
/// Verify and validate a decoded `SAMLResponse` XML document.
pub fn consume(
xml: &str,
sp: &SpParams,
idp: &IdpMetadata,
now: OffsetDateTime,
clock_skew: Duration,
) -> Result<VerifiedResponse, SamlError> {
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
// `trusted_keys_only` ignores any cert embedded in the document's
// KeyInfo, so an attacker can't substitute their own key.
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
// 2. Parse for semantic validation.
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "Response" {
return Err(SamlError::MissingElement("Response".into()));
}
let response_id = root.attribute("ID").map(str::to_owned);
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
// 3. Status must be Success.
let status_value = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
.and_then(|sc| sc.attribute("Value"))
.unwrap_or("");
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
return Err(SamlError::Status(status_value.to_owned()));
}
// 4. Destination (if the IdP set one) must be our ACS.
if let Some(dest) = root.attribute("Destination") {
if !urls_equal(dest, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
// 5. Exactly one (unencrypted) Assertion.
if root
.descendants()
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
{
return Err(SamlError::EncryptedAssertionUnsupported);
}
let assertions: Vec<Node<'_, '_>> = root
.children()
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
.collect();
if assertions.len() != 1 {
return Err(SamlError::AssertionCount(assertions.len()));
}
let assertion = assertions[0];
let assertion_id = assertion
.attribute("ID")
.map(str::to_owned)
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
// 6. The signature must actually cover the assertion we're about to trust:
// either the assertion itself, the enclosing response, or the whole
// document. (bergshamra's strict_verification already constrains where
// the signed element may sit; this ties it to *our* assertion.)
let covers_assertion = verified_uris.iter().any(|u| {
u.is_empty()
|| u == &format!("#{assertion_id}")
|| response_id
.as_ref()
.is_some_and(|rid| u == &format!("#{rid}"))
});
if !covers_assertion {
return Err(SamlError::SignatureScope);
}
// 7. Issuer must be the configured IdP.
let issuer = first_child(assertion, "Issuer")
.map(text_of)
.unwrap_or_default();
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
return Err(SamlError::IssuerMismatch);
}
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
let subject = first_child(assertion, "Subject")
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
let name_id = first_child(subject, "NameID")
.map(text_of)
.filter(|s| !s.is_empty())
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
if let Some(scd) = subject
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
{
if let Some(recipient) = scd.attribute("Recipient") {
if !urls_equal(recipient, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
if let Some(noa) = scd.attribute("NotOnOrAfter") {
let noa = parse_instant(noa)?;
if now >= noa + clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
// 9. Conditions: time window + audience.
let conditions = first_child(assertion, "Conditions");
if let Some(cond) = conditions {
if let Some(nb) = cond.attribute("NotBefore") {
let nb = parse_instant(nb)?;
if now < nb - clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
let assertion_expiry = conditions
.and_then(|c| c.attribute("NotOnOrAfter"))
.map(parse_instant)
.transpose()?
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
if now >= assertion_expiry + clock_skew {
return Err(SamlError::TimeBounds);
}
let audience_ok = conditions.is_some_and(|c| {
c.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
.any(|a| text_of(a) == sp.entity_id)
});
if !audience_ok {
return Err(SamlError::AudienceMismatch);
}
// 10. Optional: SessionIndex + display-name attribute.
let session_index = assertion
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
.and_then(|a| a.attribute("SessionIndex"))
.map(str::to_owned);
let display_name = extract_display_name(assertion);
Ok(VerifiedResponse {
principal: VerifiedPrincipal {
email: name_id.clone(),
name_id,
display_name,
},
in_response_to,
assertion_id,
assertion_expiry,
session_index,
})
}
/// Verify the document's XML-DSig against each pinned IdP cert in turn
/// (handles key rotation), returning the verified `<Reference>` URIs.
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
let mut last_err = String::from("no signing certificate matched");
for der in certs_der {
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
Ok(k) => k,
Err(e) => {
last_err = e.to_string();
continue;
}
};
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
// trusted_keys_only: only ever trust the pinned IdP key, never an
// inline KeyInfo cert. strict_verification: XSW positional defense.
let ctx = bergshamra::DsigContext::new(km)
.with_trusted_keys_only(true)
.with_strict_verification(true);
match bergshamra::verify(&ctx, xml) {
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
return Ok(references.into_iter().map(|r| r.uri).collect());
}
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
Err(e) => last_err = e.to_string(),
}
}
Err(SamlError::Signature(last_err))
}
/// Pull a display name from the assertion's attribute statement, trying the
/// common attribute names IdPs use (FleetDM checks the same set).
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
const WANTED: &[&str] = &[
"name",
"displayname",
"cn",
"urn:oid:2.5.4.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
];
for attr in assertion
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
{
let key = attr
.attribute("Name")
.or_else(|| attr.attribute("FriendlyName"))
.unwrap_or("")
.to_ascii_lowercase();
if WANTED.contains(&key.as_str()) {
if let Some(val) = attr
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
{
let v = text_of(val);
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
n.children()
.find(|c| c.is_element() && c.tag_name().name() == local)
}
fn text_of(n: Node<'_, '_>) -> String {
n.children()
.filter(Node::is_text)
.filter_map(|c| c.text())
.collect::<String>()
.trim()
.to_owned()
}
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
}
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
/// only by a single trailing slash.
fn urls_equal(a: &str, b: &str) -> bool {
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
}
-287
View File
@@ -1,287 +0,0 @@
//! End-to-end SAML SP tests.
//!
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
//! template with `bergshamra::sign` (the same engine that verifies it), and
//! drive [`response::consume`] through the accept path and every reject path.
//! This proves both the signature wiring and the SP-semantic checks.
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{response, SamlError, SpParams};
const SP_ENTITY: &str = "https://pxe.example.com";
const ACS: &str = "https://pxe.example.com/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
const EMAIL: &str = "[email protected]";
struct TestIdp {
cert_der: Vec<u8>,
key_pem: String,
}
fn test_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
TestIdp {
cert_der: ck.cert.der().as_ref().to_vec(),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn fmt(t: OffsetDateTime) -> String {
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
}
/// Knobs for building a response template — defaults are a valid response.
struct Resp {
issuer: String,
audience: String,
status: String,
not_before: OffsetDateTime,
not_on_or_after: OffsetDateTime,
in_response_to: Option<String>,
recipient: String,
}
impl Default for Resp {
fn default() -> Self {
let now = OffsetDateTime::now_utc();
Self {
issuer: IDP_ENTITY.into(),
audience: SP_ENTITY.into(),
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
not_before: now - Duration::minutes(5),
not_on_or_after: now + Duration::hours(1),
in_response_to: Some("_req-abc".into()),
recipient: ACS.into(),
}
}
}
impl Resp {
/// The unsigned template (a `<ds:Signature>` with empty values).
fn template(&self) -> String {
let now = fmt(OffsetDateTime::now_utc());
let irt = self
.in_response_to
.as_ref()
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{ACS}"{irt}>
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{EMAIL}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"##,
issuer = self.issuer,
status = self.status,
audience = self.audience,
recipient = self.recipient,
nb = fmt(self.not_before),
noa = fmt(self.not_on_or_after),
)
}
}
fn sign(template: &str, key_pem: &str) -> String {
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
.expect("load test signing key");
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, template).expect("sign test response")
}
fn sp() -> SpParams {
SpParams {
entity_id: SP_ENTITY.into(),
acs_url: ACS.into(),
}
}
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
IdpMetadata {
entity_id: IDP_ENTITY.into(),
sso_redirect_url: None,
sso_post_url: None,
signing_certs_der: vec![cert_der],
}
}
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
response::consume(
xml,
&sp(),
&idp(cert_der),
OffsetDateTime::now_utc(),
Duration::seconds(60),
)
}
#[test]
fn good_response_yields_principal() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("valid response should verify");
assert_eq!(out.principal.email, EMAIL);
assert_eq!(out.principal.name_id, EMAIL);
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
assert_eq!(out.assertion_id, "_assertion1");
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
}
#[test]
fn tampered_assertion_is_rejected() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
// Flip the subject email after signing — breaks the digest.
let tampered = signed.replace(EMAIL, "[email protected]");
assert_ne!(signed, tampered);
assert!(matches!(
consume(&tampered, t.cert_der),
Err(SamlError::Signature(_) | SamlError::SignatureScope)
));
}
#[test]
fn unsigned_response_is_rejected() {
let t = test_idp();
// Feed the *unsigned* template (empty SignatureValue) straight in.
let unsigned = Resp::default().template();
assert!(matches!(
consume(&unsigned, t.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_signing_key_is_rejected() {
let signer = test_idp();
let other = test_idp(); // different keypair pinned as the "IdP" cert
let signed = sign(&Resp::default().template(), &signer.key_pem);
assert!(matches!(
consume(&signed, other.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let t = test_idp();
let r = Resp {
audience: "https://someone-else.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::AudienceMismatch)
));
}
#[test]
fn expired_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now - Duration::hours(2),
not_on_or_after: now - Duration::hours(1),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn future_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now + Duration::hours(1),
not_on_or_after: now + Duration::hours(2),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let t = test_idp();
let r = Resp {
issuer: "https://evil-idp.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::IssuerMismatch)
));
}
#[test]
fn non_success_status_is_rejected() {
let t = test_idp();
let r = Resp {
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::Status(_))
));
}
#[test]
fn idp_initiated_has_no_in_response_to() {
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
let t = test_idp();
let r = Resp {
in_response_to: None,
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
assert!(out.in_response_to.is_none());
}
+12 -84
View File
@@ -1,17 +1,16 @@
//! SAML SSO configuration — FleetDM-shaped.
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label. As of v0.5.1 the SAML login flow is wired
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! endpoint, pure-Rust signature verification, and operator-session
//! minting. This module owns only the persisted *configuration*.
//! human-readable label; v0.4.5 just persists it. The actual SAML
//! response-validation / JIT-provisioning flow lands in a later release
//! — for now we cover the "configurable" half so an operator can teach
//! OpenPXE about their IdP today and flip the switch on next upgrade.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! IdP-authenticated user the SP cryptographically verifies gets an
//! operator session; there is no per-user role table). Entity ID is
//! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
//! have access or you don't). Entity ID is omitted from the operator
//! UI per the v0.4.5 brief — it defaults to the advertised public base
//! URL when SAML wiring lands, which is what most IdPs expect anyway.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
@@ -48,18 +47,6 @@ pub struct SsoConfig {
/// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)]
pub metadata_url: String,
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
/// Empty falls back to the advertised public base URL at runtime, which
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
#[serde(default)]
pub entity_id: String,
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
/// initiated by identity provider". Default off; SP-initiated (the
/// "Sign in with X" button) is always allowed regardless.
#[serde(default)]
pub allow_idp_initiated: bool,
}
impl SsoConfig {
@@ -69,7 +56,8 @@ impl SsoConfig {
/// surface a yellow "configured but not live yet" hint.
#[must_use]
pub fn is_usable(&self) -> bool {
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
self.enabled
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
}
}
@@ -120,12 +108,6 @@ impl SsoStore {
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
cfg.entity_id = cfg.entity_id.trim().to_string();
if cfg.entity_id.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"entity_id exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
@@ -235,8 +217,6 @@ mod tests {
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
drop(s);
@@ -259,8 +239,6 @@ mod tests {
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert!(s.snapshot().is_usable());
@@ -276,8 +254,6 @@ mod tests {
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
@@ -294,8 +270,6 @@ mod tests {
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
@@ -313,8 +287,6 @@ mod tests {
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
@@ -324,51 +296,9 @@ mod tests {
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert_eq!(
s.snapshot().idp_logo_url,
"https://idp.example.com/logo.png"
);
}
#[test]
fn entity_id_and_idp_initiated_round_trip() {
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Keycloak".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: "https://pxe.example.com".into(),
allow_idp_initiated: true,
})
.unwrap();
drop(s);
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
assert_eq!(cfg.entity_id, "https://pxe.example.com");
assert!(cfg.allow_idp_initiated);
}
#[test]
fn entity_id_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: "x".repeat(MAX_URL_LEN + 1),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
}
#[test]
@@ -382,8 +312,6 @@ mod tests {
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
-210
View File
@@ -1,210 +0,0 @@
//! Wake-on-LAN.
//!
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
//! local segment. The NIC's WoL logic matches the repeated MAC and
//! powers the board on.
//!
//! ## Why this is trivial and safe in our container
//!
//! - It's a single UDP datagram to a broadcast address. No privileged
//! *local* port is needed (we bind an ephemeral source port); the
//! destination port is conventionally 9 (discard) or 7 (echo), and
//! nothing actually listens there — the magic is in the payload, not
//! the port. So WoL works without any extra capability.
//! - We send to the limited broadcast `255.255.255.255` (stays on the
//! local link) and, when the caller knows the server's own subnet
//! broadcast, to that too — directed broadcast reaches the right VLAN
//! even when the host bridges multiple segments.
//!
//! ## Limits
//!
//! WoL only crosses L2. If the target is on a different subnet than the
//! OpenPXE host, the intervening router must be configured to forward
//! directed broadcasts (most aren't, by design). For the common case —
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
//! is enough.
use crate::{Error, Result};
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
/// Conventional WoL destination port. 9 (discard) is the de-facto
/// default; the port is immaterial since the match is on the payload.
const WOL_PORT: u16 = 9;
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
///
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
// Strip every non-hex-digit, then expect exactly 12 hex chars.
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
if hex.len() != 12 {
return Err(Error::Invalid(format!(
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
hex.len()
)));
}
let mut out = [0u8; 6];
for (i, byte) in out.iter_mut().enumerate() {
// Each octet is two hex chars; unwrap is safe — we validated
// the length and that every char is a hex digit above.
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
}
Ok(out)
}
/// Build the 102-byte magic packet for `mac`.
#[must_use]
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
let mut pkt = [0u8; 102];
// 6 bytes of 0xFF.
for b in &mut pkt[..6] {
*b = 0xFF;
}
// MAC repeated 16 times.
for rep in 0..16 {
let start = 6 + rep * 6;
pkt[start..start + 6].copy_from_slice(&mac);
}
pkt
}
/// Send a Wake-on-LAN magic packet for `mac` to every address in
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
/// broadcast). Returns the number of broadcast addresses the packet was
/// successfully sent to; errors only if the MAC is malformed or the
/// socket can't be opened at all.
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
let parsed = parse_mac(mac)?;
let packet = magic_packet(parsed);
// Always include the limited broadcast even if the caller didn't —
// it's the one that works with zero network configuration.
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
for b in broadcasts {
if !targets.contains(b) {
targets.push(*b);
}
}
let sent = send_magic(&packet, &targets, WOL_PORT)?;
tracing::info!(
target: "openpxe::wol",
mac = %mac, broadcasts = sent,
"Wake-on-LAN magic packet sent"
);
Ok(sent)
}
/// Open a broadcast-enabled UDP socket and send `packet` to every
/// `target:port`. Returns how many sends succeeded. Errors if the
/// socket can't be opened or if *no* target accepted the packet.
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
// must be enabled to send to a broadcast address.
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
sock.set_broadcast(true)
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
let mut sent = 0usize;
for &addr in targets {
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
Ok(_) => sent += 1,
Err(e) => {
tracing::warn!(
target: "openpxe::wol",
broadcast = %addr,
"WoL send failed: {e}"
);
}
}
}
if sent == 0 {
return Err(Error::Invalid(
"Wake-on-LAN: no broadcast address accepted the packet".into(),
));
}
Ok(sent)
}
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
/// Used so the caller can include the server's own subnet broadcast
/// alongside the limited broadcast.
#[must_use]
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
let ip = u32::from(ip);
let mask = u32::from(mask);
Ipv4Addr::from(ip | !mask)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mac_accepts_common_forms() {
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
}
#[test]
fn parse_mac_rejects_bad_length() {
assert!(parse_mac("aa:bb:cc").is_err());
assert!(parse_mac("").is_err());
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
}
#[test]
fn magic_packet_shape() {
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
assert_eq!(&pkt[..6], &[0xFF; 6]);
// First MAC repetition.
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
// Last (16th) repetition ends the packet.
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
}
#[test]
fn subnet_broadcast_computes() {
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(192, 168, 1, 49),
Ipv4Addr::new(255, 255, 255, 0)
),
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
Ipv4Addr::new(10, 5, 255, 255)
);
}
#[test]
fn send_magic_delivers_intact_packet_over_loopback() {
// Deterministic round-trip that doesn't depend on the sandbox
// permitting a real L2 broadcast: bind a receiver on loopback
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
assert_eq!(sent, 1);
let mut buf = [0u8; 128];
let n = rx.recv(&mut buf).unwrap();
assert_eq!(n, 102, "magic packet should be 102 bytes");
assert_eq!(&buf[..102], &packet[..]);
}
}
-17
View File
@@ -4,10 +4,6 @@ version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
# populated at build time — the About-tab update check derives the
# Gitea releases API URL from it.
repository.workspace = true
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
[lints]
@@ -38,14 +34,6 @@ mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
# About-tab "check for updates" call to the Gitea releases API; SMTP for
# email notifications. Both use rustls so the static musl binary stays
# OpenSSL-free.
reqwest.workspace = true
lettre.workspace = true
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
base64.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -56,8 +44,3 @@ time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
+34 -301
View File
@@ -31,8 +31,8 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
@@ -116,16 +116,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/login", post(auth_api::api_login))
.route("/api/logout", post(auth_api::api_logout))
.route("/api/me", get(auth_api::api_me))
.route("/api/me/credentials", put(auth_api::api_update_credentials))
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
// operator pastes their IdP metadata, Entity ID, and toggles here.
.route(
"/api/me/credentials",
put(auth_api::api_update_credentials),
)
// v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
// The actual sign-in flow lands in a later release; this just
// gives operators a place to paste their IdP metadata today.
.route("/api/sso", get(api_sso_get).put(api_sso_put))
// v0.5.1: SAML SP login flow (pre-auth — see the require_auth
// allowlist). /login redirects to the IdP, /acs consumes the signed
// response + mints a session, /metadata serves our SP descriptor.
.route("/api/sso/login", get(crate::saml_routes::sso_login))
.route("/api/sso/acs", post(crate::saml_routes::sso_acs))
.route("/api/sso/metadata", get(crate::saml_routes::sso_metadata))
.route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings))
@@ -140,19 +138,13 @@ pub fn build_router(state: AppState) -> Router {
// modules (Unraid), and no container-side configuration could
// load a host kernel module. `smbclient` speaks SMB over a
// plain TCP socket in userspace, works in every container.
.route(
"/api/smb-shares",
get(api_smb_shares_list).post(api_smb_shares_add),
)
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
.route(
"/api/nfs-shares",
get(api_nfs_shares_list).post(api_nfs_shares_add),
)
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
@@ -167,19 +159,9 @@ pub fn build_router(state: AppState) -> Router {
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
// limited broadcast + the server's own subnet broadcast.
.route("/api/hosts/:mac/wol", post(api_hosts_wol))
// Rolling "host log" of boot events: what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
// v0.5.0: notification config (Advanced tab) + a "send test"
// probe. GET redacts the SMTP password.
.route("/api/notify", get(api_notify_get).put(api_notify_put))
.route("/api/notify/test", post(api_notify_test))
// v0.5.0: About-tab update check — queries the Gitea releases
// API and compares against the running version.
.route("/api/updates/check", get(api_updates_check))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
@@ -212,7 +194,9 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
match state.sso.replace(body) {
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
@@ -257,7 +241,8 @@ async fn index(State(state): State<AppState>) -> Response {
/// with the `?v=<version>` query string in index.html, the practical
/// upper bound on caching across an upgrade is "until the operator
/// reloads".
const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate");
const ASSET_CACHE_CONTROL: HeaderValue =
HeaderValue::from_static("no-cache, must-revalidate");
async fn ui_js() -> Response {
(
@@ -352,7 +337,9 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
(Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
(Some(path), Some(mime)) if mime != "image/svg+xml" => {
tokio::fs::read(&path).await.ok()
}
_ => None,
};
@@ -626,22 +613,11 @@ async fn boot_sub(
.filter(|m| !m.is_empty());
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: mac_normalized.clone(),
mac: mac_normalized,
ip: peer_ip,
target_id: entry.id.clone(),
target_title: format!("{} — {}", iso.filename, entry.title),
});
// v0.5.0: fire-and-forget notification on the
// canonical "a machine is imaging" moment.
let who = mac_normalized
.clone()
.or_else(|| peer_ip.map(|ip| ip.to_string()))
.unwrap_or_else(|| "an unknown client".into());
spawn_notify(
&state,
"PXE boot started",
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
);
return text_plain(render_entry(entry, &settings, base));
}
}
@@ -696,7 +672,9 @@ async fn iso_raw(
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
IsoSource::Smb {
@@ -711,10 +689,7 @@ async fn iso_raw(
if headers.get(header::RANGE).is_some() {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(
header::CONTENT_RANGE,
format!("bytes */{}", meta.size_bytes),
)
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
.body(Body::empty())
.unwrap();
}
@@ -1052,7 +1027,10 @@ async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Val
// ─── Branding (custom logo) ───────────────────────────────────────────────
async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multipart) -> Response {
async fn api_branding_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "logo" {
@@ -1073,7 +1051,9 @@ async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multi
// hitting disk. Logos are tiny by definition.
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
}
};
if bytes.len() > MAX_LOGO_BYTES {
return (
@@ -1101,7 +1081,9 @@ async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multi
)
.into_response()
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
@@ -1265,25 +1247,10 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol",
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
],
},
{
"name": "Notifications & updates",
"endpoints": [
{"method": "GET", "path": "/api/notify",
"summary": "Current notification config (SMTP password redacted)."},
{"method": "PUT", "path": "/api/notify",
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
{"method": "POST", "path": "/api/notify/test",
"summary": "Send a test notification using the saved config."},
{"method": "GET", "path": "/api/updates/check",
"summary": "Compare the running version against the latest Gitea release."},
],
},
{
"name": "Operator console",
"endpoints": [
@@ -2055,218 +2022,6 @@ async fn api_hosts_remove(
}
}
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
/// broadcast (255.255.255.255) and the server's own subnet broadcast
/// (computed from the advertised IP + detected mask), which covers the
/// common "same VLAN as OpenPXE" case with zero network config. We only
/// wake MACs that are actually bound — keeps this from being an open
/// "spray packets at any MAC" endpoint.
async fn api_hosts_wol(State(state): State<AppState>, AxumPath(mac): AxumPath<String>) -> Response {
if state.hosts.lookup(&mac).is_none() {
return (
StatusCode::NOT_FOUND,
"no host binding for that MAC — bind it first",
)
.into_response();
}
// Compute the server's subnet broadcast from the advertised IP +
// detected mask so the packet reaches the right VLAN even if the
// limited broadcast is filtered. Best-effort: skip if either won't
// parse.
let server_ip = state
.public_base_url
.strip_prefix("http://")
.unwrap_or(&state.public_base_url)
.split(':')
.next()
.unwrap_or("")
.parse::<std::net::Ipv4Addr>();
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
let mut broadcasts = Vec::new();
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
broadcasts.push(wol::subnet_broadcast(ip, m));
}
// The send is a blocking std UDP call; push it off the async
// executor.
let mac_owned = mac.clone();
let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
match result {
Ok(Ok(n)) => {
// Fire-and-forget notification — nice "someone woke a box"
// signal, never blocks the response.
spawn_notify(
&state,
"Wake-on-LAN sent",
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
);
Json(json!({ "ok": true, "broadcasts": n })).into_response()
}
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("wol task failed: {e}"),
)
.into_response(),
}
}
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
// Redact the SMTP password before it leaves the process.
Json(state.notify.snapshot().redacted())
}
async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> Response {
match state.notify.replace(cfg) {
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
/// Send a test notification using the *currently saved* config (not the
/// request body) so the operator validates exactly what's persisted.
async fn api_notify_test(State(state): State<AppState>) -> Response {
let cfg = state.notify.snapshot();
match crate::notify::send(
&cfg,
"OpenPXE test notification",
"If you're reading this, OpenPXE notifications are wired up correctly. \
This is a test from the Advanced settings tab.",
)
.await
{
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
}
}
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
/// Query the project's Gitea releases API for the latest published tag
/// and compare it to the running version. This is the only outbound
/// call OpenPXE makes that isn't operator-initiated data movement, and
/// it's strictly on-demand (the About tab's "Check for updates" button)
/// — never a background poll, keeping the air-gapped promise intact.
async fn api_updates_check() -> Response {
let current = env!("CARGO_PKG_VERSION");
let Some(api) = gitea_releases_api_url() else {
return Json(json!({
"current": current,
"error": "repository URL not configured at build time",
}))
.into_response();
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
.build()
{
Ok(c) => c,
Err(e) => {
return Json(json!({ "current": current, "error": format!("client: {e}") }))
.into_response();
}
};
match client.get(&api).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
let latest_tag = body
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let html_url = body
.get("html_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current);
Json(json!({
"current": current,
"latest": latest_tag,
"update_available": update_available,
"html_url": html_url,
}))
.into_response()
}
Ok(resp) => Json(json!({
"current": current,
"error": format!("releases API returned HTTP {}", resp.status()),
}))
.into_response(),
Err(e) => Json(json!({
"current": current,
"error": format!("could not reach the releases API: {e}"),
}))
.into_response(),
}
}
/// Derive the Gitea `releases/latest` API URL from the compile-time
/// repository URL (`https://host/owner/repo`).
fn gitea_releases_api_url() -> Option<String> {
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
let rest = repo
.strip_prefix("https://")
.or_else(|| repo.strip_prefix("http://"))?;
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
let host = parts.next()?;
let owner = parts.next()?;
let name = parts.next()?;
if host.is_empty() || owner.is_empty() || name.is_empty() {
return None;
}
Some(format!(
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
))
}
/// Compare two dotted numeric versions; `true` when `latest` is strictly
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
/// a garbage tag never falsely reports an update.
fn version_is_newer(latest: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.split('.')
.map(|p| {
p.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
})
.map(|s| s.parse::<u64>().unwrap_or(0))
.collect()
}
let (l, c) = (parts(latest), parts(current));
for i in 0..l.len().max(c.len()) {
let lv = l.get(i).copied().unwrap_or(0);
let cv = c.get(i).copied().unwrap_or(0);
if lv != cv {
return lv > cv;
}
}
false
}
/// Fire a notification on a detached task. Never blocks the caller and
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
let cfg = state.notify.snapshot();
if !cfg.is_usable() {
return;
}
let subject = subject.to_string();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
}
});
}
// ─── Boot event log ───────────────────────────────────────────────────────
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
@@ -2326,28 +2081,6 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
mod tests {
use super::*;
#[test]
fn version_newer_detects_updates() {
assert!(version_is_newer("0.5.1", "0.5.0"));
assert!(version_is_newer("0.6.0", "0.5.9"));
assert!(version_is_newer("1.0.0", "0.9.9"));
assert!(!version_is_newer("0.5.0", "0.5.0"));
assert!(!version_is_newer("0.4.69", "0.5.0"));
// A garbage / empty tag must never falsely report an update.
assert!(!version_is_newer("", "0.5.0"));
assert!(!version_is_newer("not-a-version", "0.5.0"));
}
#[test]
fn gitea_api_url_derives_from_repo() {
// The crate inherits the workspace `repository`, so
// CARGO_PKG_REPOSITORY is populated and the update check has a
// real URL to hit (regressed once when the inherit was missing).
let u = gitea_releases_api_url().expect("repository must be configured at build time");
assert!(u.contains("/api/v1/repos/"), "got: {u}");
assert!(u.ends_with("/releases/latest"), "got: {u}");
}
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000).unwrap();
+16 -47
View File
@@ -140,16 +140,9 @@ fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
}
/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
cookie_attrs(session, None)
format!(
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
)
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
@@ -176,18 +169,9 @@ fn is_public_path(path: &str) -> bool {
return true;
}
// Auth surface and iPXE long-poll endpoints (no cookie available).
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
// session yet when they start (or arrive from) the IdP. `/api/sso`
// (the config GET/PUT, no trailing slash) stays gated.
matches!(
path,
"/api/setup"
| "/api/login"
| "/api/logout"
| "/api/me"
| "/api/sso/login"
| "/api/sso/acs"
| "/api/sso/metadata"
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/")
}
@@ -238,7 +222,10 @@ pub struct SetupBody {
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
pub async fn api_setup(
State(state): State<AppState>,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
@@ -293,7 +280,10 @@ pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody
login_response(StatusCode::OK, &pub_, &session)
}
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
pub async fn api_logout(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
@@ -313,20 +303,12 @@ pub async fn api_logout(State(state): State<AppState>, headers: axum::http::Head
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response();
@@ -341,8 +323,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
@@ -351,8 +331,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
Json(json!({
"setup_required": false,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
@@ -459,14 +437,8 @@ mod tests {
fn public_path_allowlist() {
// PXE + chrome paths bypass auth.
for p in [
"/",
"/assets/app.js",
"/boot.ipxe",
"/boot/fake.ipxe",
"/iso/fake.iso",
"/ipxe/snponly.efi",
"/healthz",
"/readyz",
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
@@ -478,10 +450,6 @@ mod tests {
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc"));
@@ -503,7 +471,8 @@ mod tests {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
.unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None.
-2
View File
@@ -18,8 +18,6 @@ pub mod auth;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod notify;
pub mod saml_routes;
pub mod state;
pub mod terminal;
pub mod uploads;
-138
View File
@@ -1,138 +0,0 @@
//! Notification *delivery* — the network half of the notify feature.
//!
//! `openpxe_core::notify` owns the config + persistence; this module
//! turns a `NotifyConfig` + a message into an actual delivery:
//!
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
//! body to the operator's incoming-webhook URL (via `reqwest`).
//! - SMTP → a TLS email via `lettre`.
//!
//! Every send is best-effort and time-bounded: a flaky webhook must
//! never wedge a PXE boot. Callers fire these from a detached task.
use openpxe_core::{NotifyConfig, NotifyKind};
use std::time::Duration;
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
/// task forever.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
/// Deliver `body` (with an optional `subject`, used as the email
/// subject / chat bold-line) using the active provider in `cfg`.
/// Returns `Ok(())` on success, or a human-readable error suitable for
/// surfacing in the "Send test" response.
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
if !cfg.is_usable() {
return Err("notifications are not enabled / fully configured".into());
}
match cfg.kind {
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
send_webhook(cfg, subject, body).await
}
NotifyKind::Smtp => send_email(cfg, subject, body).await,
}
}
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
// Each chat platform wants a different JSON shape for an incoming
// webhook. Keep the bodies minimal and plain-text-ish so they
// render cleanly everywhere.
let combined = if subject.is_empty() {
body.to_string()
} else {
format!("*{subject}*\n{body}")
};
let payload = match cfg.kind {
NotifyKind::Slack => serde_json::json!({ "text": combined }),
NotifyKind::Discord => serde_json::json!({ "content": combined }),
NotifyKind::Teams => serde_json::json!({
// Legacy MessageCard — the format every Teams "Incoming
// Webhook" connector still accepts.
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
"title": subject,
"text": body,
}),
NotifyKind::Smtp => unreachable!("smtp handled separately"),
};
let client = reqwest::Client::builder()
.timeout(SEND_TIMEOUT)
.build()
.map_err(|e| format!("could not build HTTP client: {e}"))?;
let resp = client
.post(&cfg.webhook_url)
.json(&payload)
.send()
.await
.map_err(|e| format!("webhook POST failed: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let snippet = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
Err(format!("webhook returned HTTP {status}: {snippet}"))
}
}
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from = if cfg.smtp_from.trim().is_empty() {
cfg.smtp_username.trim()
} else {
cfg.smtp_from.trim()
};
if from.is_empty() {
return Err("SMTP requires a From address (or a username to fall back to)".into());
}
let email = Message::builder()
.from(
from.parse()
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
)
.to(cfg
.smtp_to
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
let mut builder = if cfg.smtp_implicit_tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
}
.port(cfg.smtp_port)
.timeout(Some(SEND_TIMEOUT));
// Auth is optional — some internal relays accept unauthenticated
// mail from trusted hosts. Only attach credentials when a username
// is set.
if !cfg.smtp_username.trim().is_empty() {
builder = builder.credentials(Credentials::new(
cfg.smtp_username.trim().to_string(),
cfg.smtp_password.clone(),
));
}
let mailer = builder.build();
mailer
.send(email)
.await
.map(|_| ())
.map_err(|e| format!("SMTP send failed: {e}"))
}
-338
View File
@@ -1,338 +0,0 @@
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
//!
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
//! ID, and 302 the browser to the IdP.
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
//! correlation, IdP-initiated gating, assertion replay), mint an operator
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
//!
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
//! module owns only the HTTP glue and the in-memory state the SP needs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration as StdDuration, Instant};
use axum::{
body::Body,
extract::{Form, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use base64::Engine;
use parking_lot::Mutex;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
use openpxe_core::SsoConfig;
use crate::auth;
use crate::state::AppState;
/// Outstanding AuthnRequest IDs live at most this long before a matching
/// response is considered stale (covers a slow human at the IdP login form).
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
/// How long we fetch-cache IdP metadata loaded from a URL.
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
#[derive(Clone, Default)]
pub struct SamlRuntime {
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
/// assertion_id → expiry. A consumed assertion may not be replayed.
consumed: Arc<Mutex<HashMap<String, Instant>>>,
/// Cache of IdP metadata fetched from a URL: (url, parsed).
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
}
impl SamlRuntime {
/// Record an AuthnRequest we just sent.
pub fn register_request(&self, id: &str) {
let mut g = self.outstanding.lock();
prune(&mut g);
g.insert(id.to_owned(), Instant::now());
}
/// Consume an outstanding request ID, returning `true` if it was present
/// and still fresh. A miss means the response doesn't correlate to any
/// live request we issued.
pub fn take_request(&self, id: &str) -> bool {
let mut g = self.outstanding.lock();
prune(&mut g);
g.remove(id).is_some()
}
/// Record a consumed assertion. Returns `false` if it was already
/// consumed (a replay) — in which case the caller must reject.
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
let mut g = self.consumed.lock();
prune(&mut g);
if g.contains_key(id) {
return false;
}
let ttl = (expiry - OffsetDateTime::now_utc())
.max(Duration::ZERO)
.unsigned_abs();
g.insert(id.to_owned(), Instant::now() + ttl);
true
}
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
let g = self.metadata_cache.lock();
match &*g {
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
_ => None,
}
}
fn cache_metadata(&self, url: String, md: IdpMetadata) {
*self.metadata_cache.lock() = Some((url, md));
}
}
/// Drop expired entries so neither map grows unbounded.
fn prune(map: &mut HashMap<String, Instant>) {
let now = Instant::now();
// For the request map this over-prunes (entries store issued_at, not
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
// expiry instants. Using saturating logic keeps both correct: request
// entries older than REQUEST_TTL go, consumed entries past expiry go.
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
}
// ─── GET /api/sso/login ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
/// Optional local path to return to after login (becomes RelayState).
#[serde(default)]
pub next: Option<String>,
}
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
return redirect("/?sso_error=metadata");
};
let sp = sp_params(&state, &cfg);
let relay = safe_local_path(q.next.as_deref());
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
Ok(req) => {
state.saml.register_request(&req.id);
redirect(&req.location)
}
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
redirect("/?sso_error=request")
}
}
}
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AcsForm {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState", default)]
pub relay_state: Option<String>,
}
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
return redirect("/?sso_error=1");
}
};
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let sp = sp_params(&state, &cfg);
// Signature verification + semantic checks are CPU-bound — keep them off
// the async executor.
let now = OffsetDateTime::now_utc();
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
let verify = {
let xml = xml.clone();
let sp = sp.clone();
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
.await
};
let verified = match verify {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
// Never leak which specific check failed to the browser.
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
return redirect("/?sso_error=1");
}
Err(join) => {
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
return redirect("/?sso_error=1");
}
};
// Stateful checks the core deliberately left to us.
match &verified.in_response_to {
Some(id) => {
if !state.saml.take_request(id) {
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
return redirect("/?sso_error=1");
}
}
None => {
if !cfg.allow_idp_initiated {
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
return redirect("/?sso_error=idp_initiated");
}
}
}
if !state
.saml
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
{
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
return redirect("/?sso_error=1");
}
// Success → mint an operator session keyed to the verified email.
let session = state.sessions.create(&verified.principal.email);
tracing::info!(
target: "openpxe::saml",
email = %verified.principal.email,
idp_initiated = verified.in_response_to.is_none(),
"SAML SSO sign-in"
);
// safe_local_path already maps None / unsafe values to "/".
let relay = safe_local_path(form.relay_state.as_deref());
redirect_with_session(&relay, &session)
}
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
let cfg = state.sso.snapshot();
let sp = sp_params(&state, &cfg);
let xml = saml::metadata::build_sp_metadata(&sp);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
xml,
)
.into_response()
}
// ─── helpers ────────────────────────────────────────────────────────────────
/// Derive runtime SP parameters from config + the advertised public base URL.
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
let base = state.public_base_url.trim_end_matches('/');
let entity_id = if cfg.entity_id.trim().is_empty() {
base.to_owned()
} else {
cfg.entity_id.trim().to_owned()
};
SpParams {
entity_id,
acs_url: format!("{base}/api/sso/acs"),
}
}
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
/// the "URL wins" rule, else parse the pasted XML.
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
let url = cfg.metadata_url.trim();
if !url.is_empty() {
if let Some(md) = state.saml.cached_metadata(url) {
return Ok(md);
}
let body = fetch_metadata(url).await?;
let md = IdpMetadata::parse(&body)?;
state.saml.cache_metadata(url.to_owned(), md.clone());
return Ok(md);
}
if !cfg.metadata.trim().is_empty() {
return IdpMetadata::parse(&cfg.metadata);
}
Err(SamlError::Metadata("no metadata source configured".into()))
}
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
let client = reqwest::Client::builder()
.timeout(METADATA_FETCH_TIMEOUT)
.build()
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
if !resp.status().is_success() {
return Err(SamlError::Metadata(format!(
"fetch {url}: HTTP {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
}
/// Only permit a same-site path (single leading slash) as a redirect target —
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
fn safe_local_path(p: Option<&str>) -> String {
match p {
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
_ => "/".to_owned(),
}
}
fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
fn redirect_with_session(location: &str, session: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, auth::session_cookie(session))
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
+4 -13
View File
@@ -1,9 +1,8 @@
use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore,
Metrics, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use std::sync::Arc;
@@ -35,17 +34,9 @@ pub struct AppState {
/// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore,
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
/// flow ships in a later release.
pub sso: SsoStore,
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
/// (for InResponseTo correlation), consumed-assertion replay guard, and
/// a cache of fetched IdP metadata. Tied to process lifetime, like
/// `sessions`; a restart simply invalidates any in-flight SSO login.
pub saml: SamlRuntime,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
+10 -367
View File
@@ -102,7 +102,6 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
let state = AppState {
@@ -116,8 +115,6 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
admin,
sessions,
sso,
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify,
metrics,
smb: None,
smb_shares,
@@ -538,57 +535,6 @@ async fn smb_shares_list_starts_empty() {
// v0.4.67: NFSv3 share manager (parallel to SMB).
// ── v0.5.0: notifications + Wake-on-LAN ────────────────────────────────────
#[tokio::test]
async fn notify_config_round_trips_and_redacts_smtp_password() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Save an SMTP config with a password.
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"[email protected]","smtp_from":"[email protected]","smtp_password":"s3cret"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
// GET must redact the password (never echo the real secret).
let (s, b) = get(&app, "/api/notify").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["enabled"], true);
assert_eq!(v["kind"], "smtp");
let pw = v["smtp_password"].as_str().unwrap_or("");
assert_ne!(pw, "s3cret", "raw password must never be returned");
assert!(
!pw.is_empty(),
"a set password should surface as a sentinel"
);
}
#[tokio::test]
async fn notify_enable_webhook_without_url_is_rejected() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn wol_on_unbound_mac_is_404() {
// WoL only fires for bound MACs — an arbitrary MAC must 404 so the
// endpoint isn't an open packet sprayer.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn nfs_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
@@ -1555,11 +1501,7 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
// ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
async fn post_collect(
router: &axum::Router,
path: &str,
body: &str,
) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
let res = router
.clone()
.oneshot(
@@ -1970,10 +1912,7 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert!(
body.starts_with(b"\x89PNG"),
"should serve default PNG for SVG"
);
assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG");
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
assert_eq!(width, 1024);
}
@@ -1985,7 +1924,10 @@ async fn pxe_logo_composes_to_1024x768_png() {
// the iPXE menu always paints at consistent dimensions.
let (state, _dir) = build_state().await;
let png = tiny_png();
state.branding.set_logo("image/png", "png", &png).unwrap();
state
.branding
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
let res = app
.clone()
@@ -2025,7 +1967,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
// auth allowlist gates `/api/*` only.
let (state, _dir) = build_state().await;
let png = tiny_png();
state.branding.set_logo("image/png", "png", &png).unwrap();
state
.branding
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
// Configure an admin so the middleware kicks in.
let (s, _, _) = post_collect(
@@ -2039,305 +1984,3 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
let (s, _) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::OK);
}
// ─── v0.5.1: SAML SSO flow ──────────────────────────────────────────────────
//
// The core crate exhaustively tests signature verification + semantic
// validation (crates/core/src/saml/tests.rs). These integration tests cover
// the HTTP wiring the core can't: routing, base64 decode, session minting,
// the InResponseTo / IdP-initiated gating, and assertion-replay rejection.
use base64::Engine as _;
use openpxe_core::SsoConfig;
use time::format_description::well_known::Rfc3339;
use time::{Duration as TimeDuration, OffsetDateTime};
const SP_BASE: &str = "http://127.0.0.1"; // build_state's public_base_url
const SP_ACS: &str = "http://127.0.0.1/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.test/realms/fleet";
const IDP_SSO: &str = "https://idp.test/realms/fleet/protocol/saml";
struct TestIdp {
cert_b64: String,
key_pem: String,
}
fn make_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.test".to_string()]).unwrap();
let der = ck.cert.der().as_ref().to_vec();
TestIdp {
cert_b64: base64::engine::general_purpose::STANDARD.encode(der),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn idp_metadata_xml(cert_b64: &str) -> String {
format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{IDP_ENTITY}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_b64}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{IDP_SSO}"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#
)
}
/// Build + sign a SAMLResponse with the test IdP key. `in_response_to: None`
/// makes it an unsolicited (IdP-initiated) response.
fn signed_response(idp: &TestIdp, in_response_to: Option<&str>) -> String {
let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap();
let fmt = |t: OffsetDateTime| t.format(&Rfc3339).unwrap();
let irt = in_response_to
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
let template = format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{SP_ACS}"{irt}>
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{IDP_ENTITY}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">[email protected]</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{SP_ACS}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{SP_BASE}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-1">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
</saml:Assertion>
</samlp:Response>"##,
now = fmt(now),
nb = fmt(now - TimeDuration::minutes(5)),
noa = fmt(now + TimeDuration::hours(1)),
);
let key = bergshamra::keys::loader::load_pem_auto(idp.key_pem.as_bytes(), None).unwrap();
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, &template).unwrap()
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase());
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase());
}
}
}
out
}
fn configure_sso(state: &AppState, metadata: String, allow_idp_initiated: bool) {
state
.sso
.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
idp_logo_url: String::new(),
metadata,
metadata_url: String::new(),
entity_id: String::new(),
allow_idp_initiated,
})
.unwrap();
}
async fn post_acs(router: &axum::Router, signed_xml: &str) -> axum::response::Response {
let b64 = base64::engine::general_purpose::STANDARD.encode(signed_xml.as_bytes());
let body = format!("SAMLResponse={}", urlencode(&b64));
router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sso/acs")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
fn has_session_cookie(resp: &axum::response::Response) -> bool {
resp.headers().get_all(header::SET_COOKIE).iter().any(|v| {
let s = v.to_str().unwrap_or("");
s.starts_with("openpxe_session=")
&& !s.contains("openpxe_session=;")
&& !s.contains("Max-Age=0")
})
}
fn location(resp: &axum::response::Response) -> String {
resp.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned()
}
#[tokio::test]
async fn sso_login_redirects_to_idp() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false);
let app = build_router(state);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/sso/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
let loc = location(&resp);
assert!(loc.starts_with(IDP_SSO), "redirect to IdP, got {loc}");
assert!(
loc.contains("SAMLRequest="),
"carries SAMLRequest, got {loc}"
);
}
#[tokio::test]
async fn sso_login_unavailable_when_disabled() {
let (state, _dir) = build_state().await;
let app = build_router(state); // SSO never configured
let resp = app
.oneshot(
Request::builder()
.uri("/api/sso/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
}
#[tokio::test]
async fn sso_metadata_is_served() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (status, body) = get(&app, "/api/sso/metadata").await;
assert_eq!(status, StatusCode::OK);
let xml = String::from_utf8(body).unwrap();
assert!(xml.contains("SPSSODescriptor"));
assert!(xml.contains(SP_ACS));
assert!(xml.contains(SP_BASE));
}
#[tokio::test]
async fn acs_idp_initiated_mints_session() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let signed = signed_response(&idp, None);
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(location(&resp), "/");
assert!(
has_session_cookie(&resp),
"ACS must set an operator session cookie"
);
}
#[tokio::test]
async fn acs_idp_initiated_blocked_when_disabled() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), false); // gate OFF
let app = build_router(state);
let signed = signed_response(&idp, None);
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(
!has_session_cookie(&resp),
"no session when IdP-initiated is disabled"
);
}
#[tokio::test]
async fn acs_sp_initiated_without_known_request_is_rejected() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
// A valid signature but an InResponseTo we never issued => reject.
let signed = signed_response(&idp, Some("_never-issued"));
let resp = post_acs(&app, &signed).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp));
}
#[tokio::test]
async fn acs_replayed_assertion_is_rejected() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let signed = signed_response(&idp, None);
// First use succeeds…
let first = post_acs(&app, &signed).await;
assert!(has_session_cookie(&first));
// …replaying the identical assertion is rejected.
let second = post_acs(&app, &signed).await;
assert_eq!(second.status(), StatusCode::FOUND);
assert!(location(&second).contains("sso_error"));
assert!(!has_session_cookie(&second));
}
#[tokio::test]
async fn acs_garbage_is_rejected_without_500() {
let (state, _dir) = build_state().await;
let idp = make_idp();
configure_sso(&state, idp_metadata_xml(&idp.cert_b64), true);
let app = build_router(state);
let body = "SAMLResponse=not%20valid%20base64%21%21";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sso/acs")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp));
}
-3
View File
@@ -107,7 +107,6 @@ async fn main() -> anyhow::Result<()> {
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 notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
@@ -168,8 +167,6 @@ async fn main() -> anyhow::Result<()> {
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify: notify.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
smb_shares: smb_shares.clone(),
-51
View File
@@ -627,14 +627,6 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
}
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
/* v0.5.0: FleetDM-style custom logo on the login/setup card — the
uploaded logo spans the card header and the "OpenPXE" wordmark is
dropped (the logo is the brand). Matches the sidebar treatment. */
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
.auth-card .brand-row.has-custom-logo img {
width: auto; height: 52px; max-width: 240px;
object-fit: contain; object-position: center;
}
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
@@ -797,46 +789,3 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
(the former Advanced sidebar tab). A quiet, full-width toggle that
expands to reveal the notification + API-reference cards. */
.advanced-disclosure { width: 100%; }
.advanced-summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: var(--fg-dim);
font-size: 13px;
font-weight: 600;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.advanced-summary:hover { color: var(--fg); }
.advanced-summary::-webkit-details-marker { display: none; }
.advanced-summary::before {
content: "▸";
font-size: 11px;
transition: transform 0.15s ease;
}
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
.proto-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 6px;
margin-right: 8px;
border-radius: 4px;
vertical-align: middle;
background: var(--bg-panel-2);
border: 1px solid var(--border);
color: var(--fg-dim);
}
+190 -414
View File
@@ -684,17 +684,57 @@
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── Remote shares section (v0.5.1) ──
// SMB + NFS unified into one "Remote shares" card with a protocol
// dropdown. The two protocols keep their own backend endpoints
// (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
// UX; the form just swaps the relevant fields. This declutters the
// Storage tab and leaves room for a future "Config files" card.
const shareMsg = el('div', {class:'msg'});
// ── SMB shares section (v0.4.65) ──
// Replaces the kernel-mount NFS card. SMB shares are consumed
// in userspace via Samba's `smbclient` CLI — no kernel modules,
// no CAP_SYS_ADMIN, works in any container. This is the same
// approach Bootimus uses.
const smbMsg = el('div', {class:'msg'});
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
// Toggle username/password fields based on the Guest checkbox so
// operators don't get confused about which fields matter.
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
// Shared structured-error renderer ({error, stderr, hint}) for both
// protocols' add calls.
const showShareError = async (r) => {
const addSmb = el('button', {onclick: async () => {
if (!smbServer.value || !smbShare.value) {
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
smbMsg.className='msg err'; return;
}
if (!smbGuest.checked && !smbUser.value) {
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
smbMsg.className='msg err'; return;
}
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
smbMsg.className = 'msg';
const body = {
server: smbServer.value,
share: smbShare.value,
guest: smbGuest.checked,
};
if (!smbGuest.checked) {
body.username = smbUser.value;
body.password = smbPass.value;
}
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage');
} else {
// The API returns a structured {error, stderr, hint} JSON
// body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
@@ -708,47 +748,15 @@
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
shareMsg.replaceChildren(...parts);
shareMsg.className = 'msg err';
};
smbMsg.replaceChildren(...parts);
smbMsg.className = 'msg err';
}
}}, 'Add share');
// Protocol picker — swaps which field block is visible.
const protoSelect = el('select', {}, [
el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
]);
// SMB inputs.
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const smbFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
]),
]);
const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
document.createTextNode('//' + m.server + '/' + m.share)]),
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'},
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
@@ -765,75 +773,59 @@
render('storage');
}}, 'Remove'),
el('span'),
]));
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
// NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
// NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
// mid-stream. No auth fields: NFSv3 access is gated by client IP on
// the server's export list, not client-supplied credentials.
// ── NFS shares section (v0.4.67) ──
// Parallel to SMB shares above. The NFSv3 client is in-process
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
// requests — SMB-sourced ones don't (smbclient CLI can't seek
// mid-stream). Otherwise the UX is identical: server + export,
// submit, scan, remove.
const nfsMsg = el('div', {class:'msg'});
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
]),
]);
// Swap the visible field block + clear any stale message.
const syncProto = () => {
const nfs = protoSelect.value === 'nfs';
smbFields.style.display = nfs ? 'none' : '';
nfsFields.style.display = nfs ? '' : 'none';
shareMsg.replaceChildren();
shareMsg.className = 'msg';
};
protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {style:'margin-top:14px', onclick: async () => {
if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
shareMsg.className = 'msg err'; return;
}
if (!smbGuest.checked && !smbUser.value) {
shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else {
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
if (!nfsServerIn.value || !nfsExportIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
shareMsg.className = 'msg err'; return;
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
nfsMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs-shares', {
server: nfsServerIn.value,
export: nfsExportIn.value,
});
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
nfsMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else {
// Structured {error, stderr, hint} same as SMB.
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
nfsMsg.replaceChildren(...parts);
nfsMsg.className = 'msg err';
}
}}, 'Add share');
const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
document.createTextNode(m.server + ':' + m.export)]),
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'meta'},
'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
'NFSv3 · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
@@ -847,13 +839,7 @@
render('storage');
}}, 'Remove'),
el('span'),
]));
const totalShares = shares.length + nfsShares.length;
const remoteRows = totalShares
? [...smbRowEls, ...nfsRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto();
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
const diskCard = diskSpaceCard(disk);
@@ -863,36 +849,77 @@
el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg]),
]),
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
// protocol dropdown. Backend endpoints are unchanged; this is a
// pure UI consolidation that declutters the Storage tab.
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Remote shares'),
el('span', {class:'sub'}, totalShares + ' configured'),
el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, shares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Protocol'),
protoSelect,
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('span'),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [
smbGuest, el('span', {}, 'Guest (anonymous read)'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Username'),
smbUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Password'),
smbPass,
]),
]),
addSmb, smbMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Remote ISO libraries are read on demand — no local cache, no ' +
'double disk usage. SMB/CIFS is read in userspace via Sambas ' +
'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' +
'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' +
'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' +
'user/password; most NAS appliances expose ISO libraries as ' +
'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' +
'allowing this OpenPXE hosts IP in the servers export list. ' +
'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' +
'without reading what precedes the offset); SMB streams sequentially.'),
'SMB shares are read in userspace via Sambas smbclient — ' +
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
'appliances expose ISO libraries as guest-readable; check the box ' +
'above when thats the case. ISOs are streamed on demand at PXE ' +
'boot time — no local cache, no double disk usage.'),
]),
]),
// v0.4.67: NFS shares card sits right below SMB so operators
// can see both protocols at a glance. The form is simpler
// (no auth) because NFSv3 access control is by client IP on
// the server side, not by client-supplied credentials.
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, nfsShares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServerIn,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExportIn,
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('p', {class:'msg', style:'margin-top:14px'},
'NFSv3 shares are read in-process via a pure-Rust client — ' +
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
'every container the SMB path works in (Unraid included). ' +
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
'by allowing this OpenPXE hosts IP in the export list. ' +
'ISOs are streamed on demand and HTTP Range requests work — ' +
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
'into a 5 GB ISO without reading what comes before.'),
]),
]),
el('div', {class:'card'}, [
@@ -946,37 +973,18 @@
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
// table is bound hosts). The button reports its own state inline
// so there's no shared toast to thread through.
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
wakeBtn.disabled = true;
const original = wakeBtn.textContent;
wakeBtn.textContent = 'Waking…';
try {
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
} catch (e) {
wakeBtn.textContent = 'Failed';
}
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
}}, 'Wake');
return el('tr', {}, [
const rows = hosts.map(h => el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [
wakeBtn,
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove'),
]),
]);
});
}}, 'Remove')),
]));
const table = hosts.length
? el('table', {}, [
@@ -1177,16 +1185,13 @@
},
settings: async () => {
const [status, me, sso, notify, docs] = await Promise.all([
const [status, docs, me, sso] = await Promise.all([
getJSON('/api/status'),
getJSON('/api/docs').catch(() => ({ groups: [] })),
getJSON('/api/me').catch(() => ({})),
getJSON('/api/sso').catch(() => ({
enabled:false, idp_name:'', metadata:'', metadata_url:'',
})),
// v0.5.1: the former Advanced tab folds in here, so Settings
// fetches the notify config + API docs it needs too.
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
getJSON('/api/docs').catch(() => ({ groups: [] })),
]);
const hasLogo = !!status.custom_logo;
@@ -1484,141 +1489,10 @@
]),
]);
// v0.5.1: the former "Advanced" sidebar tab now lives here, folded
// into a collapsible disclosure beneath the core settings cards —
// webhook/email notifications + the API reference. Keeps Settings
// clean by default while leaving the knobs one click away.
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
]);
return el('div', {}, [
el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
advanced,
]);
},
// v0.5.1: builds the two "Advanced" cards — webhook/email notifications
// and the API reference. There is no longer an Advanced sidebar tab;
// the Settings view folds these into a collapsible disclosure and
// passes in the pre-fetched `notify` + `docs` payloads.
_advancedCards: (notify, docs) => {
// ── Notification config ──
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
const nKind = el('select', {}, [
el('option', {value:'slack'}, 'Slack'),
el('option', {value:'discord'}, 'Discord'),
el('option', {value:'teams'}, 'Microsoft Teams'),
el('option', {value:'smtp'}, 'Email (SMTP)'),
]);
nKind.value = notify.kind || 'slack';
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
value: notify.webhook_url || ''});
// SMTP fields.
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
// GET returns the password as a redaction sentinel when one is
// set; show an empty field with an "(unchanged)" placeholder and
// let collectNotify() re-send the sentinel so the stored secret
// is preserved unless the operator types a new one.
const hasStoredPass = !!notify.smtp_password;
const sPass = el('input', {type:'password',
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
const webhookBlock = el('div', {class:'form-row'}, [
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Incoming webhook URL'),
nWebhook,
el('span', {class:'hint'},
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
]),
]);
const smtpBlock = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
]),
el('label', {class:'check', style:'margin-top:12px'}, [
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
]),
]);
// Toggle which provider block shows.
const syncKind = () => {
const smtp = nKind.value === 'smtp';
webhookBlock.style.display = smtp ? 'none' : '';
smtpBlock.style.display = smtp ? '' : 'none';
};
nKind.addEventListener('change', syncKind);
syncKind();
const collectNotify = () => ({
enabled: nEnabled.checked,
kind: nKind.value,
webhook_url: nWebhook.value,
smtp_host: sHost.value,
smtp_port: Number(sPort.value) || 587,
smtp_username: sUser.value,
// Empty field + a stored password → send the sentinel so the
// server keeps it. Otherwise send whatever was typed (a new
// password, or empty to clear when none was stored).
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
smtp_from: sFrom.value,
smtp_to: sTo.value,
smtp_implicit_tls: sTls.checked,
});
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen.
const rs = await putJSON('/api/notify', collectNotify());
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
const r = await postJSON('/api/notify/test', {});
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Send test');
const notifyCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Webhook notifications'),
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
'(or SMTP details), and Send test.'),
el('div', {class:'form-row cols-2'}, [
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
]),
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
saveBtn, testBtn, nMsg,
]),
]);
// ── API reference (relocated from Settings) ──
// ── API reference (always at the bottom of Settings).
// Sourced from /api/docs so the hand-curated list stays the
// single source of truth and the UI doesn't need its own copy
// baked into the JS bundle.
const groups = docs.groups || [];
const apiCard = el('div', {class:'card'}, [
el('header', {}, [
@@ -1641,41 +1515,12 @@
'No API documentation returned by /api/docs.')),
]);
return [notifyCard, apiCard];
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
},
about: async () => {
const status = await getJSON('/api/status');
// ── Check for updates ──
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
const updBtn = el('button', {onclick: async () => {
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
try {
const r = await getJSON('/api/updates/check');
if (r.error) {
updMsg.replaceChildren(document.createTextNode('Couldnt check: ' + r.error));
updMsg.className = 'msg err';
} else if (r.update_available) {
const parts = [document.createTextNode('Update available: '),
el('strong', {}, r.latest), document.createTextNode(' (youre on ' + r.current + '). ')];
if (r.html_url) {
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
'View release →'));
}
updMsg.replaceChildren(...parts);
updMsg.className = 'msg ok';
} else {
updMsg.replaceChildren(document.createTextNode(
'Youre up to date — running the latest release (' + r.current + ').'));
updMsg.className = 'msg ok';
}
} catch (e) {
updMsg.textContent = 'Couldnt reach the release feed (offline?).'; updMsg.className = 'msg err';
}
}}, 'Check for updates');
const heroCard = el('div', {class:'card'}, [
return el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'},
@@ -1690,7 +1535,6 @@
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
'https://openpxe.com/'),
]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' +
'executes is generated from the settings on these tabs — there is no ' +
@@ -1703,46 +1547,6 @@
'flip "testsigning" on a target machine.'),
]),
]);
// ── Licenses ──
const licenseCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'License')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:12px'}, [
'OpenPXE is dual-licensed under ',
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
el('strong', {}, 'Apache License 2.0'),
document.createTextNode(
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
]),
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
el('p', {class:'msg', style:'font-size:12px'},
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
'of this software and associated documentation files, to deal in the Software ' +
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
'Full MIT text →'),
]),
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
el('p', {class:'msg', style:'font-size:12px'},
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
'Full Apache 2.0 text →'),
]),
]),
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
'wimtools, and the Rust crates in this build. See the source repository for the ' +
'complete NOTICE.'),
]),
]);
return el('div', {class:'grid'}, [heroCard, licenseCard]);
},
};
@@ -1859,26 +1663,6 @@
let chipsInterval = null;
let authScreenEl = null;
let ssoConfig = null;
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
// /api/me (public). Lets the login/setup cards render the same
// FleetDM-style full-width custom logo the dashboard sidebar uses.
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
// Brand row for the auth screens. With a custom logo uploaded: the
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
// Default: bundled mark + "OpenPXE".
function authBrandRow() {
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
if (brandInfo.has_custom_logo) {
return el('div', {class:'brand-row has-custom-logo'}, [
el('img', {src, alt:'logo'}),
]);
}
return el('div', {class:'brand-row'}, [
el('img', {src, alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]);
}
function teardownAuthScreen() {
if (authScreenEl && authScreenEl.parentNode) {
@@ -1894,28 +1678,16 @@
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
// v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
// redirects back to "/?sso_error=..." on any failure; we show a
// generic, non-leaky message and scrub the query so a refresh is clean.
const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
if (ssoErr) {
err.textContent = ssoErr === 'idp_initiated'
? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
: (ssoErr === 'unavailable' || ssoErr === 'metadata')
? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
: 'SSO sign-in failed. Please try again, or sign in with the local admin.';
err.style.display = '';
window.history.replaceState({}, '', window.location.pathname);
}
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
? el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login (v0.5.1): hand off to the IdP. The
// /api/sso/acs endpoint verifies the response, mints the
// operator session, and redirects back to the dashboard.
window.location.assign('/api/sso/login');
// SSO login flow lands in a later release — for now we
// surface a friendly note so the operator knows the config
// landed but the runtime hookup is pending.
err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.';
err.style.display = '';
}}, [
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
el('div', {class:'meta'}, 'configured · runtime flow pending'),
])
: null;
@@ -1947,7 +1719,10 @@
submit.textContent = 'Sign in';
}
}}, [
authBrandRow(),
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
el('label', {class:'field'}, [
@@ -2012,7 +1787,10 @@
submit.textContent = 'Create administrator';
}
}}, [
authBrandRow(),
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'First-run setup'),
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
el('label', {class:'field'}, [
@@ -2128,17 +1906,15 @@
let me;
try {
me = await fetch('/api/me').then(r => r.json());
// Capture branding so the auth cards render the custom logo.
brandInfo = {
has_custom_logo: !!me.has_custom_logo,
logo_rev: me.logo_rev || 0,
};
} catch (e) {
// /api/me is unauthenticated in every state — if we can't reach
// it the server is genuinely down, not an auth problem.
document.body.appendChild(el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, [
authBrandRow(),
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'Connection error'),
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
])));
@@ -1,199 +0,0 @@
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
**Date:** 2026-05-31
**Author:** Miles Ward (with Claude)
**Status:** Approved design → implementation
## Summary
Three workstreams for v0.5.1:
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
sign-in exists). Pure-Rust implementation that preserves the static-musl /
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
with a protocol dropdown.
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
to Gitea, create the release, and scrub registry credentials.
## Decisions (locked with the user)
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
musl binary and the project's pure-Rust / no-OpenSSL architecture.
- **Access model:** any SAML assertion the IdP successfully authenticates and that
we cryptographically verify mints a full operator session. No user table, no
roles, no domain allowlist. The local admin account remains a guaranteed
fallback owner regardless of SSO state.
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
provider."
## Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
mapping.
---
## Workstream 1 — SAML SP wiring (pure-Rust)
### New dependencies (workspace)
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
metadata + SAMLResponse, build AuthnRequest and SP metadata.
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
- `base64` — encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
crate source during implementation.
### Module boundaries
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
HTTP wiring lives in `openpxe-http-api`.
- `crates/core/src/saml/mod.rs` — public surface + shared types
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
`SingleSignOnService` locations + bindings, and one or more X.509 signing
certificates. Also build **our** SP metadata XML.
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
request ID (to track) and the encoded HTTP-Redirect query value
(deflate → base64 → URL-encode).
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
**verify the signature via bergshamra** against the IdP cert, then enforce SP
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
### SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
1. `Status` is `Success`.
2. `Destination` (if present) equals our ACS URL.
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified `Reference` covers the element we read claims from.
### State (in `openpxe-http-api`)
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates
in-flight logins.)
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
### Config changes (`crates/core/src/sso.rs`)
Add to `SsoConfig` (preserve existing fields + validation):
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
the configured public base URL. The ACS URL is derived as
`<public_base_url>/api/sso/acs`.
- `allow_idp_initiated: bool` — default `false`.
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
### Login page (`crates/webui/src/app.js`)
Replace the "configured · runtime pending" message: the existing
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
FleetDM-style login layout.
### Testing
- `core/saml` unit tests using a self-signed test keypair we control:
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
replayed assertion ID, unsigned response, `Status != Success`.
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
---
## Workstream 2 — Advanced tab → Settings
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
`advanced` view route in `app.js`.
- In the Settings view, append a **collapsible "Advanced" disclosure**
(default-collapsed) at the bottom containing the existing **Webhook
Notifications** card and the **API reference** block (moved out of the removed
Advanced view).
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
---
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
**"Remote shares"** card:
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
swaps the fields: SMB → server, share, guest checkbox, username, password;
NFS → server, export path.
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
actions.
- **No backend changes.** The form dispatches to the existing
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
protocol. Re-scan/Remove call the existing per-protocol endpoints.
- Leaves the card pattern open for a future "Config files" card.
---
## Release
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
3. Build the static musl binary + Docker image; verify SAML deps compile clean
under musl (no OpenSSL/C linkage).
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
5. Create the Gitea release `v0.5.1` with notes.
## Risks
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
verification, keep the local-admin fallback, and own the SP-semantic checks
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
are part of the definition of done, not optional.