diff --git a/.gitignore b/.gitignore
index 7f0c94a..505ca0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ vmlinuz
bin
public/tftp/*
public/node_modules
+scripts/node_modules
public/http/css/tailwind.css
public/tftp/pxelinux.cfg/*
public/provision/configs/cloud-init/*
diff --git a/Dockerfile.release b/Dockerfile.release
index dce0308..0183955 100644
--- a/Dockerfile.release
+++ b/Dockerfile.release
@@ -21,14 +21,19 @@ WORKDIR /app
# Download the pre-built binary from GitHub release
# Use curl with follow redirects for better GitHub releases handling
-RUN curl -L -o ./ignite https://github.com/chadleeshaw/ignite/releases/download/v2.0.0/ignite-linux-amd64 && \
+RUN curl -L -o ./ignite https://github.com/chadleeshaw/ignite/releases/download/v3.0.0/ignite-linux-amd64 && \
chmod +x ./ignite
# Copy public files
COPY --chown=ignite:ignite ./public ./public
-# Create data directory for database
+# Create the data directory before the volume so a new named volume is
+# owned by ignite (uid 1001). The database and Syslinux cache live here.
RUN mkdir -p /app/data && chown ignite:ignite /app/data
+VOLUME /app/data
+
+# Declared after COPY so a new named volume is seeded from ./public/tftp.
+VOLUME /app/public/tftp
# Switch to non-root user
USER ignite
@@ -36,6 +41,6 @@ USER ignite
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD curl -f http://localhost:8080/status || exit 1
+ CMD curl -f http://localhost:${HTTP_PORT}/ || exit 1
CMD ["./ignite"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 2e8e754..4d22cd5 100755
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Ignite
-
+
Ignite is a powerful, all-in-one solution for network booting and server provisioning, implemented in Go. It simplifies the complexities of setting up network boot environments by integrating DHCP, TFTP, and a web-based management interface into a single, easy-to-use application.
@@ -36,42 +36,27 @@ Designed for developers, system administrators, and hobbyists, Ignite provides a
## Screenshots
-Here's a comprehensive look at the Ignite web interface across all major features:
+The nav is Overview, DHCP, TFTP, OS Images, Syslinux, and Provision. Cozy is the default density. Console is the same layout in a monospace, tighter rhythm. Service health that used to live on a separate Status page is on Overview. Both Overview shots are 1280 pixels wide.
-### **Main Dashboard**
-The central hub providing system overview, quick actions, and navigation to all features.
+### **Overview**
+Service tiles for HTTP, TFTP, and DHCP, plus the lease and pool summary slot.
-
+
+
+
Cozy
+
Console
+
+
-### **DHCP Management**
-Complete DHCP server management with lease monitoring, reservations, and dynamic IP allocation.
+### **Other pages**
-
-
-### **TFTP File Management**
-File browser and management interface for boot files, operating system images, and PXE configurations.
-
-
-
-### **Provision Templates**
-Cloud-init and Kickstart template editor with syntax highlighting, template management, file deletion, and clear distinction between templates and configs.
-
-
-
-### **OS Images Management**
-Download, manage, and deploy operating system images with progress tracking and version control.
-
-
-
-### **Syslinux Configuration**
-Advanced PXE boot menu configuration and SYSLINUX management interface.
-
-
-
-### **System Status**
-Real-time monitoring of server status, services, and system health indicators.
-
-
+- DHCP: [Cozy](./public/http/img/dhcp_page.png) · [Console](./public/http/img/dhcp_console.png)
+- DHCP detail: [Cozy](./public/http/img/dhcp_detail.png) · [Console](./public/http/img/dhcp_detail_console.png)
+- TFTP: [Cozy](./public/http/img/tftp_page.png) · [Console](./public/http/img/tftp_console.png)
+- OS Images: [Cozy](./public/http/img/osimage_page.png) · [Console](./public/http/img/osimage_console.png)
+- Syslinux: [Cozy](./public/http/img/syslinux_page.png) · [Console](./public/http/img/syslinux_console.png)
+- Provision: [Cozy](./public/http/img/provision_page.png) · [Console](./public/http/img/provision_console.png)
+- Login: [Cozy](./public/http/img/login_page.png) · [Console](./public/http/img/login_console.png)
## Getting Started
@@ -126,6 +111,16 @@ go run vmtest.go logs # Show test logs
go run vmtest.go clean # Clean up test files
```
+**README screenshots**:
+
+Regenerate the images under `public/http/img/` with headless Chrome. TCP 8080 and UDP 69 must be free. The target builds Ignite, seeds a temporary database (DHCP leases, OS images, and Syslinux versions), and writes a Cozy and a Console capture of each nav page, the login page, and the DHCP host-detail state. Overview is captured at 1280×800.
+
+```bash
+make screenshots
+```
+
+`CHROME_PATH` overrides the Chrome binary. `scripts/capture.sh` is the same flow without Make.
+
**Code Quality**:
```bash
@@ -152,13 +147,74 @@ Ignite can be configured via environment variables. Here are the available optio
| Variable | Description | Default |
|-------------|------------------------------------------------|--------------------|
-| `DB_PATH` | Path to store the database file. | `./` |
+| `DB_PATH` | Path to store the database file. The Syslinux cache defaults to a `syslinux` directory under this path. | `./` |
| `DB_FILE` | Name of the database file. | `ignite.db` |
| `DB_BUCKET` | Database bucket name. | `dhcp` |
-| `TFTP_DIR` | Directory for the TFTP server to serve files from. | `./public/tftp` |
+| `TFTP_DIR` | Directory for the TFTP server to serve files from. Created on startup. The release image sets `/app/public/tftp`. | `./public/tftp` |
| `HTTP_DIR` | Directory for the HTTP server to serve files from. | `./public/http` |
-| `HTTP_PORT` | Port for the HTTP server to listen on. | `8080` |
+| `HTTP_PORT` | TCP port for the HTTP server. | `8080` |
+| `TFTP_PORT` | UDP port for the TFTP server. Ports below 1024 need extra privileges. Set this above 1024 to run TFTP as the unprivileged `ignite` user. | `69` |
+| `DHCP_ENABLED` | When `false`, `0`, `no`, or `off`, no DHCP listener starts. Every server stays `configured` and does not make overall health `partial` or `down`. Starting one from the UI or API returns 409. Unset and any other value leave DHCP enabled. | `true` |
+| `SYSLINUX_CACHE_DIR` | Download and extract cache for Syslinux. Created on startup. Empty uses `$DB_PATH/syslinux` (`/app/data/syslinux` in the image). | `$DB_PATH/syslinux` |
| `PROV_DIR` | Directory for provisioning templates. | `./public/provision` |
+| `BMC_TLS_VERIFY` | Check the Redfish BMC certificate. Unset skips verification, which is what a self-signed homelab BMC needs. `strconv.ParseBool` values turn it on (`1`, `t`, `T`, `TRUE`, `true`, `True`) or off (`0`, `f`, `F`, `FALSE`, `false`, `False`). Any other value logs a warning and stays off. | `false` |
+| `BMC_SYSTEM_ID` | Redfish ComputerSystem `Id` when a BMC exposes more than one system. A `system_id` form field on `POST /pxe/submit_ipmi` overrides this. | first and only system |
+| `COOKIE_SECURE` | `Secure` flag on `ignite_session` and `ignite_mode`. `auto` sets it only when the request is TLS or `X-Forwarded-Proto` is `https`. `true` always sets it. `false` never does. Any other value is `auto`. The CSRF token is not a cookie. | `auto` |
+
+Startup logs the resolved TFTP root (`TFTP root: ...`), which is the absolute directory the server reads and writes. `TFTP_DIR` overrides the default. The directory is created if it is missing. When that directory cannot be created, Ignite falls back to a temporary directory named `ignite-tftp.*` under the system temp dir and logs a warning that boot files, OS images, and per-MAC configs will not survive a restart. The same warning is logged when `TFTP_DIR` itself points at a temp directory; the override is still used. The TFTP page shows the configured `TFTP_DIR` as given (`./public/tftp`, or `/app/public/tftp` in the image). When startup had to serve a temporary directory, the page shows that temporary path instead.
+
+Both container images declare `VOLUME /app/public/tftp` after `./public` is copied in, and `VOLUME /app/data` after that directory is created and given to the `ignite` user (uid 1001). A fresh TFTP volume starts with the files shipped in the image (`boot.ipxe` and the OS directories). A fresh data volume starts empty and owned by uid 1001, which is who writes `/app/data/ignite.db` and `/app/data/syslinux`. Mount both by name so the database, the Syslinux cache, boot files, downloaded OS images, and per-MAC configs survive a container recreate:
+
+```bash
+docker run -d \
+ --name ignite \
+ -p 8080:8080 \
+ -p 69:69/udp \
+ -v ignite-data:/app/data \
+ -v ignite-tftp:/app/public/tftp \
+ ghcr.io/chadleeshaw/ignite:v3.0.0
+```
+
+Compose and a Swarm stack use the same syntax (`docker stack deploy -c docker-compose.yml ignite`):
+
+```yaml
+services:
+ ignite:
+ image: ghcr.io/chadleeshaw/ignite:v3.0.0
+ ports:
+ - "8080:8080"
+ - "69:69/udp"
+ volumes:
+ - ignite-data:/app/data
+ - ignite-tftp:/app/public/tftp
+volumes:
+ ignite-data:
+ ignite-tftp:
+```
+
+Startup logs the Syslinux cache (`Syslinux cache: ...`). `SYSLINUX_CACHE_DIR` overrides the default. Versions downloaded before this change lived in `/tmp/syslinux-downloads`, which is not copied. Download those versions again.
+
+A bind mount of an empty host directory (`-v /path/on/host:/app/public/tftp` when `/path/on/host` is empty) hides those shipped files. Seed the host directory from the image, give it to the `ignite` user (uid 1001), then start Ignite with the bind mount. `docker cp` onto the host writes the files as root, and the app cannot create boot files in a directory it does not own. A named volume keeps the `ignite` ownership on its own.
+
+```bash
+mkdir -p /path/on/host
+docker create --name ignite-seed ghcr.io/chadleeshaw/ignite:v3.0.0
+docker cp ignite-seed:/app/public/tftp/. /path/on/host
+docker rm ignite-seed
+sudo chown -R 1001:1001 /path/on/host
+```
+
+Upgrading a v2.x container that was started without these mounts: copy the database and the TFTP directory out before removing the container, recreate it with both named volumes, and restore the files. `docker cp` into the new container writes as root, so return ownership to the `ignite` user (uid 1001) afterward. Syslinux archives extracted under `/tmp/syslinux-downloads` are not in either copy. Download those versions again after the upgrade. Redfish skips BMC certificate checks unless `BMC_TLS_VERIFY=true`.
+
+```bash
+docker cp :/app/data/ignite.db ./ignite.db
+docker cp :/app/public/tftp ./tftp-backup
+# remove the old container, then start the new one with
+# -v ignite-data:/app/data -v ignite-tftp:/app/public/tftp
+docker cp ./ignite.db :/app/data/ignite.db
+docker cp ./tftp-backup/. :/app/public/tftp
+docker exec -u root chown -R ignite:ignite /app/data /app/public/tftp
+```
## API Reference
@@ -169,11 +225,12 @@ Ignite exposes a set of RESTful APIs to control and manage the server.
| Endpoint | Description |
|-------------------------|------------------------------------------|
| `/` | Serves the main index page. |
+| `/status` | Redirects to Overview (`/`). |
+| `/status/content` | Status fragment Overview polls. |
| `/open_modal` | Opens a modal dialog. |
| `/close_modal` | Closes a modal dialog. |
| `/dhcp` | Serves the DHCP management page. |
-| `/dhcp/servers` | Retrieves a list of DHCP servers. |
-| `/status` | Serves the server status page. |
+| `/dhcp/servers` | DHCP server list. HTML fragment for htmx; JSON when `?format=json` or `Accept` is `application/json` without `text/html`. |
| `/provision` | Serves the provisioning page. |
| `/tftp` | Serves the TFTP management page. |
| `/tftp/open` | Opens a file in the TFTP directory. |
@@ -188,6 +245,58 @@ Ignite exposes a set of RESTful APIs to control and manage the server.
| `/provision/load-file` | Loads file content via API. |
| `/provision/gallery` | Retrieves template gallery items. |
+`GET /dhcp/servers` returns an HTML fragment (`text/html`) for browsers and
+htmx. Add `?format=json`, or send `Accept: application/json` without
+`text/html`, for JSON:
+
+```json
+{
+ "servers": [
+ {
+ "id": "server-id",
+ "tftpip": "192.168.1.1",
+ "status": "running",
+ "lease_range": 50,
+ "used": 1,
+ "free": 49,
+ "in_flight": 0,
+ "failed": 0,
+ "leases": [
+ {
+ "mac": "aa:bb:cc:dd:ee:ff",
+ "ip": "192.168.1.100",
+ "static": false,
+ "state": "assigned",
+ "state_badge_class": "badge-info",
+ "state_display_name": "Assigned"
+ }
+ ]
+ }
+ ]
+}
+```
+
+`status` is the listener state, and the same value on `/status/content`,
+`/dashboard/summary` (`pools[].status`), and the DHCP page:
+
+| Value | Meaning |
+|-------|---------|
+| `running` | The listener is bound and serving. |
+| `stopped` | It was started and then stopped, or the last start failed. |
+| `configured` | It is defined and has not been started since this process booted. |
+
+A `configured` server does not make overall health `partial`. `used` and
+`free` follow `dhcp.PoolOccupancy` (reserved or unexpired). `in_flight` and
+`failed` are separate from that partition, so a lease can be active,
+reserved, or expired and also in flight or failed. `in_flight` counts
+`pxe_requested`, `booting`, `imaging`, `imaged`, and `configuring`.
+`failed` counts `failed`. Both are on `GET /dashboard/summary` (the totals
+next to `active`, `expired`, and `reserved`, and each `pools[]` entry) and
+on each server from `GET /dhcp/servers`. Each lease
+includes `menu_name` (a friendly label such as `ubuntu 24.04`, or `""`
+when no menu is assigned) plus `menu`, `ipmi`, and `last_seen`. `menu`
+does not include a filesystem path.
+
### POST Routes
| Endpoint | Description |
@@ -203,11 +312,73 @@ Ignite exposes a set of RESTful APIs to control and manage the server.
| `/tftp/delete_file` | Deletes a file from the TFTP directory. |
| `/tftp/upload_file` | Uploads a file to the TFTP directory. |
| `/pxe/submit_menu` | Submits a new PXE boot menu. |
-| `/pxe/submit_ipmi` | Submits an IPMI command. |
+| `/pxe/submit_ipmi` | Redfish power and one-time PXE boot. Skips TLS verification unless `BMC_TLS_VERIFY=true`. Powers On when the host is off and ForceRestart only when it is on. With more than one system and no `system_id` / `BMC_SYSTEM_ID`, responds `409`. |
| `/prov/newtemplate` | Creates a new provisioning template. |
| `/prov/save` | Saves a provisioning file. |
| `/provision/save-file` | Saves file content via API. |
| `/provision/delete-file` | Deletes a file from provision directories. |
+| `/api/syslinux/activate/{version}` | Activates an already-downloaded SYSLINUX version. |
+
+`POST /api/syslinux/activate/{version}` does not download the archive. The
+version must already be downloaded. Activation removes the boot files
+currently in the TFTP directories and installs that version's extracted
+BIOS and EFI files. Other downloaded versions stay downloaded.
+
+Success is `200` with:
+
+```json
+{"success": true, "message": "Syslinux 6.03 is active", "version": "6.03"}
+```
+
+| Status | When |
+|--------|------|
+| `409` | The version exists but has not been downloaded. `error.user_message` is `This version is not downloaded. Download it before activating.` |
+| `404` | No stored version has that number. |
+| `500` | Boot files are missing or activation failed. |
+
+## CSRF
+
+Session-authenticated `POST`, `PUT`, and `DELETE` requests must send the
+per-session token and a same-host `Origin`. This includes logout,
+change-password, and every htmx POST. A missing or wrong token is `403`
+with `error.user_message` `CSRF token mismatch`. A missing `Origin`, a
+foreign `Origin`, or `Sec-Fetch-Site: cross-site` is `403` with
+`Cross-site requests are not allowed`. `POST /auth/login` and the
+per-lease host callbacks (`POST /dhcp/lease/{mac}/heartbeat` and
+`POST /dhcp/lease/{mac}/state`) are exempt. `GET` does not require a token.
+
+`ignite_mode` is an HttpOnly cookie. Page theme comes from `.Mode` in the
+HTML, not from reading that cookie in JavaScript.
+
+Every HTML render that uses the base page data includes `.CSRFToken`. The
+frontend sends it in either place:
+
+- Header `X-CSRF-Token` (htmx and fetch)
+- Form field `csrf_token`
+
+Add this to the base document, without storing the token in a cookie:
+
+```html
+
+```
+
+```html
+
+```
+
+State-changing forms also need:
+
+```html
+
+```
+
+Fetch clients that do not use htmx read the meta tag:
+
+```js
+headers: { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
+```
+
+`POST /auth/logout` needs the token when a session cookie is still present.
## Architecture
@@ -274,11 +445,30 @@ docker run -d \
--name ignite \
-p 8080:8080 \
-p 69:69/udp \
- -v $(pwd)/data:/app/data \
- -v $(pwd)/public:/app/public \
- ghcr.io/chadleeshaw/ignite:v2.0.0
+ -v ignite-data:/app/data \
+ -v ignite-tftp:/app/public/tftp \
+ ghcr.io/chadleeshaw/ignite:v3.0.0
+```
+
+Compose and a Swarm stack (`docker stack deploy -c docker-compose.yml ignite`) mount the same named volumes. `ignite-data` is `/app/data` (`ignite.db` and the Syslinux cache). `ignite-tftp` is `/app/public/tftp`. Generated provisioning files stay in `/app/public/provision`. The image does not declare a volume for that path.
+
+```yaml
+services:
+ ignite:
+ image: ghcr.io/chadleeshaw/ignite:v3.0.0
+ ports:
+ - "8080:8080"
+ - "69:69/udp"
+ volumes:
+ - ignite-data:/app/data
+ - ignite-tftp:/app/public/tftp
+volumes:
+ ignite-data:
+ ignite-tftp:
```
+A bind mount of an empty host directory onto `/app/public/tftp` hides the boot files shipped in the image. Seed that directory from the image first, as described next to `TFTP_DIR` above. The same section has the v2.x upgrade steps: copy `ignite.db` and the TFTP directory out, recreate with `ignite-data` and `ignite-tftp`, copy them back, and `chown` both to uid 1001.
+
#### **Option 2: Build from Source**
```bash
# Full build (requires Go toolchain in container)
@@ -286,18 +476,18 @@ docker build -t ignite:local .
# Quick build using release binary (faster, smaller image)
docker build -f Dockerfile.release -t ignite:release .
-docker run -d --name ignite -p 8080:8080 -p 69:69/udp ignite:release
+docker run -d --name ignite -p 8080:8080 -p 69:69/udp -v ignite-data:/app/data -v ignite-tftp:/app/public/tftp ignite:release
```
### **Binary Installation**
```bash
# Download for your platform
-wget https://github.com/chadleeshaw/ignite/releases/download/v2.0.0/ignite-linux-amd64
+wget https://github.com/chadleeshaw/ignite/releases/download/v3.0.0/ignite-linux-amd64
chmod +x ignite-linux-amd64
./ignite-linux-amd64
# Or use the x86_64 variant
-wget https://github.com/chadleeshaw/ignite/releases/download/v2.0.0/ignite-x86_64
+wget https://github.com/chadleeshaw/ignite/releases/download/v3.0.0/ignite-x86_64
chmod +x ignite-x86_64
./ignite-x86_64
```
diff --git a/app/application.go b/app/application.go
index 5aa15b0..23c7cd8 100644
--- a/app/application.go
+++ b/app/application.go
@@ -8,9 +8,12 @@ import (
"net/http"
"os"
"os/signal"
+ "strconv"
"syscall"
"time"
+ "ignite/config"
+ "ignite/dhcp"
"ignite/handlers"
"ignite/routes"
"ignite/tftp"
@@ -25,6 +28,11 @@ type Application struct {
tftpServer *tftp.Server
staticFS embed.FS
staticHandler *handlers.StaticHandlers
+
+ // rootCancel cancels the process-wide context passed to background work.
+ // Stop calls it so the lease maintainer observes cancellation.
+ rootCancel context.CancelFunc
+ maintainer *dhcp.LeaseMaintainer
}
// NewApplicationWithStatic creates a new application instance with embedded static files
@@ -45,7 +53,12 @@ func NewApplicationWithStatic(staticFS embed.FS) (*Application, error) {
SyslinuxService: container.SyslinuxService,
IPXEService: container.IPXEService,
Config: container.Config,
+ Prefs: container.Prefs,
}
+ handlers.SetPreferenceStore(container.Prefs)
+ handlers.SetPageHealth(func(r *http.Request) string {
+ return handlers.NewStatusHandlers(handlerContainer).CurrentOverallStatus(r)
+ })
return &Application{
container: container,
@@ -58,12 +71,61 @@ func NewApplicationWithStatic(staticFS embed.FS) (*Application, error) {
// Start starts all application services including static file serving
func (a *Application) Start() error {
- // Start TFTP server
- a.tftpServer = tftp.NewServer(a.container.Config.TFTP.Dir)
+ rootCtx, rootCancel := context.WithCancel(context.Background())
+ a.rootCancel = rootCancel
+ a.maintainer = dhcp.NewLeaseMaintainer(
+ a.container.LeaseService,
+ dhcp.DefaultMaintenanceInterval,
+ dhcp.DefaultOfflineThreshold,
+ )
+ a.maintainer.Start(rootCtx)
+
+ // Start TFTP server. Record the outcome so status never stays "unknown"
+ // after a start attempt, including a failed bind.
+ tftpRoot, err := config.ResolveTFTPRoot(a.container.Config.TFTP.Dir)
+ if err != nil {
+ handlers.SetTFTPRunning(false)
+ a.stopMaintenance()
+ return fmt.Errorf("failed to resolve TFTP root: %w", err)
+ }
+ for _, line := range tftpRoot.StartupLines() {
+ log.Print(line)
+ }
+ a.container.Config.TFTP.Dir = tftpRoot.Path
+ handlers.SetTFTPRoot(tftpRoot.Path)
+ handlers.SetTFTPDisplayRoot(tftpRoot.Display)
+ if a.container.SyslinuxService != nil {
+ slCfg := a.container.SyslinuxService.GetConfig()
+ slCfg.TFTPDir = tftpRoot.Path
+ _ = a.container.SyslinuxService.UpdateConfig(rootCtx, slCfg)
+ log.Printf("Syslinux cache: %s", slCfg.TempDir)
+ }
+ tftpPort, err := strconv.Atoi(a.container.Config.TFTP.Port)
+ if err != nil {
+ handlers.SetTFTPRunning(false)
+ a.stopMaintenance()
+ return fmt.Errorf("invalid TFTP port %q: %w", a.container.Config.TFTP.Port, err)
+ }
+ a.tftpServer = tftp.NewServerWithPort(tftpRoot.Path, tftpPort)
if err := a.tftpServer.Start(); err != nil {
+ handlers.SetTFTPRunning(false)
+ a.stopMaintenance()
return fmt.Errorf("failed to start TFTP server: %w", err)
}
- log.Printf("TFTP server started on port 69, serving from %s", a.container.Config.TFTP.Dir)
+ handlers.SetTFTPRunning(true)
+ log.Printf("TFTP server started on port %d, serving from %s", tftpPort, a.container.Config.TFTP.Dir)
+ if a.container.Config.DHCP.Enabled {
+ dhcp.SetListenersEnabled(true)
+ } else {
+ dhcp.SetListenersEnabled(false)
+ log.Print("DHCP is disabled")
+ }
+ log.Print(config.BMCTLSModeLog(a.container.Config.BMC.TLSVerify))
+
+ // Every page gets the same overall service word as the Overview pill.
+ handlers.SetPageHealth(func(r *http.Request) string {
+ return handlers.NewStatusHandlers(a.handlerContainer).CurrentOverallStatus(r)
+ })
// Create HTTP router with injected dependencies and static file handling
router := routes.SetupWithContainerAndStatic(a.handlerContainer, a.staticHandler)
@@ -90,8 +152,24 @@ func (a *Application) Start() error {
return nil
}
-// Rest of the Application methods remain the same...
+// stopMaintenance cancels the application context and waits for the lease
+// maintainer to finish. It is safe to call more than once.
+func (a *Application) stopMaintenance() {
+ if a.rootCancel != nil {
+ a.rootCancel()
+ a.rootCancel = nil
+ }
+ if a.maintainer != nil {
+ a.maintainer.Stop()
+ a.maintainer = nil
+ }
+}
+
func (a *Application) Stop() error {
+ // Cancel background work first and wait for the lease maintainer to
+ // leave the database alone before the rest of shutdown closes it.
+ a.stopMaintenance()
+
// Stop any running DHCP servers first
if a.container.ServerService != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -121,6 +199,7 @@ func (a *Application) Stop() error {
if a.tftpServer != nil {
a.tftpServer.Stop()
+ handlers.SetTFTPRunning(false)
}
if err := a.container.Close(); err != nil {
diff --git a/app/application_test.go b/app/application_test.go
index 7245513..5bceadf 100644
--- a/app/application_test.go
+++ b/app/application_test.go
@@ -2,19 +2,53 @@ package app
import (
"embed"
+ "net"
+ "path/filepath"
+ "strconv"
"testing"
+ "ignite/handlers"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestNewApplicationWithStatic(t *testing.T) {
+ t.Setenv("DB_PATH", t.TempDir())
// Create a simple empty embedded filesystem for testing
testFS := embed.FS{}
app, err := NewApplicationWithStatic(testFS)
- assert.NoError(t, err)
- assert.NotNil(t, app)
+ require.NoError(t, err)
+ require.NotNil(t, app)
+ t.Cleanup(func() { _ = app.Stop() })
assert.NotNil(t, app.GetContainer())
assert.NotNil(t, app.GetContainer().ServerService)
assert.NotNil(t, app.GetContainer().LeaseService)
assert.NotNil(t, app.GetContainer().Config)
}
+
+func TestStartAndStopRecordTFTPStatus(t *testing.T) {
+ t.Setenv("DB_PATH", t.TempDir())
+ t.Setenv("DB_FILE", "tftp-status.db")
+ t.Setenv("HTTP_PORT", "18081")
+ t.Setenv("TFTP_DIR", filepath.Join(t.TempDir(), "tftp"))
+ probe, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0})
+ require.NoError(t, err)
+ tftpPort := probe.LocalAddr().(*net.UDPAddr).Port
+ require.NoError(t, probe.Close())
+ t.Setenv("TFTP_PORT", strconv.Itoa(tftpPort))
+
+ app, err := NewApplicationWithStatic(embed.FS{})
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = app.Stop() })
+
+ err = app.Start()
+ if err != nil {
+ require.Equal(t, "stopped", handlers.TFTPStatus())
+ return
+ }
+ require.Equal(t, ":18081", app.httpServer.Addr)
+ require.Equal(t, "running", handlers.TFTPStatus())
+ require.NoError(t, app.Stop())
+ require.Equal(t, "stopped", handlers.TFTPStatus())
+}
diff --git a/app/container.go b/app/container.go
index cf3a4c1..580d9b0 100644
--- a/app/container.go
+++ b/app/container.go
@@ -5,6 +5,7 @@ import (
"ignite/config"
"ignite/db"
"ignite/dhcp"
+ "ignite/handlers"
"ignite/ipxe"
"ignite/osimage"
"ignite/syslinux"
@@ -24,6 +25,7 @@ type Container struct {
SyslinuxRepo syslinux.Repository
SyslinuxService syslinux.Service
IPXEService *ipxe.Service
+ Prefs handlers.PreferenceStore
}
// NewContainer creates and wires up all dependencies
@@ -49,8 +51,14 @@ func NewContainer() (c *Container, err error) {
// Create repositories
serverRepo := dhcp.NewBoltServerRepository(database, cfg.DB.Bucket+"_servers")
leaseRepo := dhcp.NewBoltLeaseRepository(database, cfg.DB.Bucket+"_leases")
- osImageRepo := osimage.NewOSImageRepository(database)
- downloadStatusRepo := osimage.NewDownloadStatusRepository(database)
+ osImageRepo, err := osimage.NewOSImageRepository(database)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create os image repository: %w", err)
+ }
+ downloadStatusRepo, err := osimage.NewDownloadStatusRepository(database)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create download status repository: %w", err)
+ }
syslinuxRepo, err := syslinux.NewBoltRepository(database)
if err != nil {
return nil, fmt.Errorf("failed to create syslinux repository: %w", err)
@@ -60,8 +68,20 @@ func NewContainer() (c *Container, err error) {
serverService := dhcp.NewDHCPServerService(serverRepo, leaseRepo)
leaseService := dhcp.NewDHCPLeaseService(leaseRepo, serverRepo)
osImageService := osimage.NewOSImageService(osImageRepo, downloadStatusRepo, cfg)
- syslinuxService := syslinux.NewService(syslinuxRepo, syslinux.GetDefaultConfig())
+ cacheDir, err := config.ResolveSyslinuxCacheDir(cfg.DB.DBPath, cfg.Syslinux.CacheDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve syslinux cache: %w", err)
+ }
+ cfg.Syslinux.CacheDir = cacheDir
+ slCfg := syslinux.GetDefaultConfig()
+ slCfg.TFTPDir = cfg.TFTP.Dir
+ slCfg.TempDir = cacheDir
+ syslinuxService := syslinux.NewService(syslinuxRepo, slCfg)
ipxeService := ipxe.NewService(cfg, osImageService)
+ prefs, err := handlers.NewBoltPreferenceStore(database, cfg.DB.Bucket+"_prefs")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create preference store: %w", err)
+ }
c = &Container{
Config: cfg,
@@ -76,6 +96,7 @@ func NewContainer() (c *Container, err error) {
SyslinuxRepo: syslinuxRepo,
SyslinuxService: syslinuxService,
IPXEService: ipxeService,
+ Prefs: prefs,
}
return c, nil
}
diff --git a/app/container_test.go b/app/container_test.go
index e79cd51..ee020d1 100644
--- a/app/container_test.go
+++ b/app/container_test.go
@@ -2,9 +2,11 @@ package app
import (
"os"
+ "path/filepath"
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestNewContainer(t *testing.T) {
@@ -30,6 +32,12 @@ func TestNewContainer(t *testing.T) {
assert.NotNil(t, container.ServerService)
assert.NotNil(t, container.LeaseService)
assert.NotNil(t, container.Config)
+ wantCache, err := filepath.Abs(filepath.Join(tempDir, "syslinux"))
+ require.NoError(t, err)
+ assert.Equal(t, wantCache, container.SyslinuxService.GetConfig().TempDir)
+ info, err := os.Stat(wantCache)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
// Clean up
container.Close()
diff --git a/public/http/img/Ignite_small.png b/assets/brand/Ignite_small.png
similarity index 100%
rename from public/http/img/Ignite_small.png
rename to assets/brand/Ignite_small.png
diff --git a/config/config.go b/config/config.go
index efbea43..8e82b89 100644
--- a/config/config.go
+++ b/config/config.go
@@ -2,8 +2,10 @@ package config
import (
"fmt"
+ "log"
"os"
"strconv"
+ "strings"
)
// Config represents the application configuration with immutable design
@@ -13,11 +15,27 @@ type Config struct {
TFTP TFTPConfig
HTTP HTTPConfig
Provision ProvisionConfig
+ Syslinux Syslinux
OSImages OSImageConfig
// ServerIP overrides the IP address advertised to network-boot clients.
// Empty means auto-detect; can also be set via the SERVER_IP environment
// variable, which takes precedence.
ServerIP string
+ // BMC controls Redfish connections to baseboard management controllers.
+ BMC BMCConfig
+}
+
+// BMCConfig controls how Ignite talks to a Redfish BMC.
+// Certificate checks are off unless TLSVerify is set. Homelab BMCs are
+// usually self-signed.
+type BMCConfig struct {
+ // TLSVerify is BMC_TLS_VERIFY. False, the default, skips certificate
+ // verification. True checks the BMC certificate.
+ TLSVerify bool
+ // SystemID is the Redfish ComputerSystem Id used when a BMC exposes more
+ // than one system. Empty means use the only system. A request can override
+ // this with the system_id form field. Env: BMC_SYSTEM_ID.
+ SystemID string
}
type DBConfig struct {
@@ -29,10 +47,14 @@ type DBConfig struct {
type DHCPConfig struct {
BiosFile string
EFIFile string
+ // Enabled is DHCP_ENABLED. False means this process does not bind a
+ // DHCP listener. Unset stays enabled. Only 0, false, no, and off disable it.
+ Enabled bool
}
type TFTPConfig struct {
- Dir string
+ Dir string
+ Port string
}
type HTTPConfig struct {
@@ -44,6 +66,12 @@ type ProvisionConfig struct {
Dir string
}
+// Syslinux is the download-cache location.
+type Syslinux struct {
+ // CacheDir is SYSLINUX_CACHE_DIR. Empty means a syslinux directory under DB_PATH.
+ CacheDir string
+}
+
type OSImageConfig struct {
Sources map[string]OSDefinition `json:"sources"`
}
@@ -79,9 +107,11 @@ func NewConfigBuilder() *ConfigBuilder {
DHCP: DHCPConfig{
BiosFile: getEnv("BIOS_FILE", "boot-bios/pxelinux.0"),
EFIFile: getEnv("EFI_FILE", "boot-efi/syslinux.efi"),
+ Enabled: dhcpEnabled(),
},
TFTP: TFTPConfig{
- Dir: getEnv("TFTP_DIR", "./public/tftp"),
+ Dir: getEnv("TFTP_DIR", "./public/tftp"),
+ Port: getEnv("TFTP_PORT", "69"),
},
HTTP: HTTPConfig{
Dir: getEnv("HTTP_DIR", "./public/http"),
@@ -90,8 +120,15 @@ func NewConfigBuilder() *ConfigBuilder {
Provision: ProvisionConfig{
Dir: getEnv("PROV_DIR", "./public/provision"),
},
+ Syslinux: Syslinux{
+ CacheDir: getEnv("SYSLINUX_CACHE_DIR", ""),
+ },
OSImages: getDefaultOSImageConfig(),
ServerIP: getEnv("SERVER_IP", ""),
+ BMC: BMCConfig{
+ TLSVerify: bmcTLSVerify(),
+ SystemID: getEnv("BMC_SYSTEM_ID", ""),
+ },
},
}
}
@@ -166,6 +203,9 @@ func (cb *ConfigBuilder) validate() error {
if port, err := strconv.Atoi(cb.config.HTTP.Port); err != nil || port < 1 || port > 65535 {
return fmt.Errorf("HTTP port must be a number between 1 and 65535, got %q", cb.config.HTTP.Port)
}
+ if port, err := strconv.Atoi(cb.config.TFTP.Port); err != nil || port < 1 || port > 65535 {
+ return fmt.Errorf("TFTP port must be a number between 1 and 65535, got %q", cb.config.TFTP.Port)
+ }
if cb.config.TFTP.Dir == "" {
return fmt.Errorf("TFTP directory cannot be empty")
}
@@ -191,6 +231,46 @@ func getEnv(key, fallback string) string {
return fallback
}
+// dhcpEnabled reports whether this process should bind DHCP listeners.
+// Unset, and any value other than 0, false, no, or off, leaves DHCP enabled
+// so a typo cannot turn the listeners off.
+func dhcpEnabled() bool {
+ value, ok := os.LookupEnv("DHCP_ENABLED")
+ if !ok {
+ return true
+ }
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "0", "false", "no", "off":
+ return false
+ default:
+ return true
+ }
+}
+
+// bmcTLSVerify reports whether Redfish should check BMC certificates.
+// Unset stays false, so a self-signed BMC connects. Parsing uses
+// strconv.ParseBool. An invalid value logs a warning and stays false.
+func bmcTLSVerify() bool {
+ value, ok := os.LookupEnv("BMC_TLS_VERIFY")
+ if !ok {
+ return false
+ }
+ verify, err := strconv.ParseBool(value)
+ if err != nil {
+ log.Printf("BMC_TLS_VERIFY=%q is not a boolean; TLS verification stays disabled", value)
+ return false
+ }
+ return verify
+}
+
+// BMCTLSModeLog is the single startup line for the effective Redfish TLS mode.
+func BMCTLSModeLog(verify bool) string {
+ if verify {
+ return "BMC TLS verification: enabled"
+ }
+ return "BMC TLS verification: disabled (set BMC_TLS_VERIFY=true to enable)"
+}
+
// getDefaultOSImageConfig returns the default OS image configuration
func getDefaultOSImageConfig() OSImageConfig {
return OSImageConfig{
diff --git a/config/config_test.go b/config/config_test.go
index 51c551b..ea910c5 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -1,6 +1,9 @@
package config
import (
+ "bytes"
+ "log"
+ "os"
"testing"
"github.com/stretchr/testify/assert"
@@ -99,6 +102,59 @@ func TestOSVersion_ExpectedChecksum(t *testing.T) {
assert.Empty(t, empty.ExpectedChecksum)
}
+func TestLoadDefault_BMCEnv(t *testing.T) {
+ unsetEnv(t, "BMC_TLS_VERIFY")
+ t.Setenv("BMC_SYSTEM_ID", "")
+ cfg, err := LoadDefault()
+ assert.NoError(t, err)
+ assert.False(t, cfg.BMC.TLSVerify)
+ assert.Empty(t, cfg.BMC.SystemID)
+
+ t.Setenv("BMC_TLS_VERIFY", "true")
+ t.Setenv("BMC_SYSTEM_ID", "System.Embedded.1")
+ cfg, err = LoadDefault()
+ assert.NoError(t, err)
+ assert.True(t, cfg.BMC.TLSVerify)
+ assert.Equal(t, "System.Embedded.1", cfg.BMC.SystemID)
+
+ t.Setenv("BMC_TLS_VERIFY", "1")
+ cfg, err = LoadDefault()
+ assert.NoError(t, err)
+ assert.True(t, cfg.BMC.TLSVerify)
+
+ t.Setenv("BMC_TLS_VERIFY", "false")
+ cfg, err = LoadDefault()
+ assert.NoError(t, err)
+ assert.False(t, cfg.BMC.TLSVerify)
+}
+
+func TestBMCTLSVerifyInvalidFallsBack(t *testing.T) {
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ t.Setenv("BMC_TLS_VERIFY", "maybe")
+ cfg, err := LoadDefault()
+ assert.NoError(t, err)
+ assert.False(t, cfg.BMC.TLSVerify)
+ assert.Contains(t, buf.String(), `BMC_TLS_VERIFY="maybe" is not a boolean`)
+ assert.Equal(t, "BMC TLS verification: disabled (set BMC_TLS_VERIFY=true to enable)", BMCTLSModeLog(false))
+ assert.Equal(t, "BMC TLS verification: enabled", BMCTLSModeLog(true))
+}
+
+func unsetEnv(t *testing.T, key string) {
+ t.Helper()
+ prev, had := os.LookupEnv(key)
+ assert.NoError(t, os.Unsetenv(key))
+ t.Cleanup(func() {
+ if had {
+ _ = os.Setenv(key, prev)
+ } else {
+ _ = os.Unsetenv(key)
+ }
+ })
+}
+
func TestLoadDefault_ServerIPEnv(t *testing.T) {
t.Setenv("SERVER_IP", "10.11.12.13")
cfg, err := LoadDefault()
diff --git a/config/dockerfile_volume_test.go b/config/dockerfile_volume_test.go
new file mode 100644
index 0000000..97fdb8f
--- /dev/null
+++ b/config/dockerfile_volume_test.go
@@ -0,0 +1,123 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestTFTPVolumeFollowsPublicCopy checks both images declare the TFTP
+// volume after ./public is copied, and that no later instruction rewrites
+// that tree. A fresh named volume is seeded from the image only when the
+// files are already in the image at the VOLUME line.
+func TestTFTPVolumeFollowsPublicCopy(t *testing.T) {
+ root := repoRoot(t)
+ _, err := os.Stat(filepath.Join(root, "public", "tftp", "boot.ipxe"))
+ require.NoError(t, err)
+
+ for _, name := range []string{"dockerfile", "Dockerfile.release"} {
+ t.Run(name, func(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join(root, name))
+ require.NoError(t, err)
+ instructions := dockerfileInstructions(string(raw))
+
+ copyIdx, volIdx := -1, -1
+ for i, line := range instructions {
+ if strings.Contains(line, "COPY --chown=ignite:ignite ./public ./public") {
+ copyIdx = i
+ }
+ if line == "VOLUME /app/public/tftp" {
+ require.Equal(t, -1, volIdx, "duplicate TFTP VOLUME")
+ volIdx = i
+ }
+ }
+ require.GreaterOrEqual(t, copyIdx, 0, "public COPY missing")
+ require.GreaterOrEqual(t, volIdx, 0, "VOLUME /app/public/tftp missing")
+ require.Greater(t, volIdx, copyIdx, "VOLUME must follow the public COPY")
+
+ for _, line := range instructions[volIdx+1:] {
+ op := strings.ToUpper(strings.Fields(line)[0])
+ switch op {
+ case "RUN", "COPY", "ADD":
+ t.Fatalf("instruction %q after VOLUME does not persist in the volume", line)
+ }
+ }
+ })
+ }
+}
+
+// TestDataVolumeFollowsChown checks both images declare VOLUME /app/data
+// after the directory is created and given to the ignite user. A fresh
+// named volume then starts owned by uid 1001, which is who writes ignite.db
+// and the Syslinux cache.
+func TestDataVolumeFollowsChown(t *testing.T) {
+ root := repoRoot(t)
+ for _, name := range []string{"dockerfile", "Dockerfile.release"} {
+ t.Run(name, func(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join(root, name))
+ require.NoError(t, err)
+ instructions := dockerfileInstructions(string(raw))
+
+ chownIdx, volIdx := -1, -1
+ for i, line := range instructions {
+ if strings.Contains(line, "mkdir -p /app/data") && strings.Contains(line, "chown ignite:ignite /app/data") {
+ require.Equal(t, -1, chownIdx, "duplicate data directory setup")
+ chownIdx = i
+ }
+ if line == "VOLUME /app/data" {
+ require.Equal(t, -1, volIdx, "duplicate data VOLUME")
+ volIdx = i
+ }
+ }
+ require.GreaterOrEqual(t, chownIdx, 0, "data directory chown missing")
+ require.GreaterOrEqual(t, volIdx, 0, "VOLUME /app/data missing")
+ require.Greater(t, volIdx, chownIdx, "VOLUME /app/data must follow the chown")
+ })
+ }
+}
+
+func dockerfileInstructions(text string) []string {
+ var instructions []string
+ var b strings.Builder
+ for _, line := range strings.Split(text, "\n") {
+ trim := strings.TrimSpace(line)
+ if trim == "" || strings.HasPrefix(trim, "#") {
+ continue
+ }
+ if b.Len() > 0 {
+ b.WriteByte(' ')
+ }
+ continued := strings.HasSuffix(trim, `\`)
+ if continued {
+ trim = strings.TrimSpace(strings.TrimSuffix(trim, `\`))
+ }
+ b.WriteString(trim)
+ if !continued {
+ instructions = append(instructions, b.String())
+ b.Reset()
+ }
+ }
+ if b.Len() > 0 {
+ instructions = append(instructions, b.String())
+ }
+ return instructions
+}
+
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ require.True(t, ok)
+ dir := filepath.Dir(file)
+ for {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ require.NotEqual(t, parent, dir, "go.mod not found")
+ dir = parent
+ }
+}
diff --git a/config/ports_test.go b/config/ports_test.go
new file mode 100644
index 0000000..ce81974
--- /dev/null
+++ b/config/ports_test.go
@@ -0,0 +1,63 @@
+package config
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoadDefaultPortsAndDHCP(t *testing.T) {
+ prev, had := os.LookupEnv("DHCP_ENABLED")
+ require.NoError(t, os.Unsetenv("DHCP_ENABLED"))
+ t.Cleanup(func() {
+ if had {
+ _ = os.Setenv("DHCP_ENABLED", prev)
+ } else {
+ _ = os.Unsetenv("DHCP_ENABLED")
+ }
+ })
+ t.Setenv("HTTP_PORT", "8080")
+ t.Setenv("TFTP_PORT", "69")
+
+ cfg, err := LoadDefault()
+ require.NoError(t, err)
+ require.Equal(t, "8080", cfg.HTTP.Port)
+ require.Equal(t, "69", cfg.TFTP.Port)
+ require.True(t, cfg.DHCP.Enabled)
+}
+
+func TestDHCPEnabledValues(t *testing.T) {
+ for _, value := range []string{"false", "0", "no", "off", "FALSE"} {
+ t.Run("disabled "+value, func(t *testing.T) {
+ t.Setenv("DHCP_ENABLED", value)
+ cfg, err := LoadDefault()
+ require.NoError(t, err)
+ require.False(t, cfg.DHCP.Enabled)
+ })
+ }
+ for _, value := range []string{"true", "1", "yes", "on", "TRUE", "maybe"} {
+ t.Run("enabled "+value, func(t *testing.T) {
+ t.Setenv("DHCP_ENABLED", value)
+ cfg, err := LoadDefault()
+ require.NoError(t, err)
+ require.True(t, cfg.DHCP.Enabled)
+ })
+ }
+}
+
+func TestTFTPPortOverrideAndValidation(t *testing.T) {
+ t.Setenv("TFTP_PORT", "6969")
+ t.Setenv("HTTP_PORT", "18080")
+ cfg, err := LoadDefault()
+ require.NoError(t, err)
+ require.Equal(t, "6969", cfg.TFTP.Port)
+ require.Equal(t, "18080", cfg.HTTP.Port)
+
+ for _, port := range []string{"", "abc", "0", "65536", "-1"} {
+ b := NewConfigBuilder()
+ b.config.TFTP.Port = port
+ _, err := b.Build()
+ require.Error(t, err, "port %q", port)
+ }
+}
diff --git a/config/syslinux_cache.go b/config/syslinux_cache.go
new file mode 100644
index 0000000..8754682
--- /dev/null
+++ b/config/syslinux_cache.go
@@ -0,0 +1,38 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// SyslinuxCacheDirName is the directory created under DB_PATH when
+// SYSLINUX_CACHE_DIR is unset.
+const SyslinuxCacheDirName = "syslinux"
+
+// ResolveSyslinuxCacheDir chooses the Syslinux download and extract cache.
+//
+// A non-empty configured path (the SYSLINUX_CACHE_DIR override) is kept.
+// An empty path is a syslinux directory under dbPath, which is /app/data/syslinux
+// when the image's DB_PATH is /app/data/. The directory is created. The
+// returned path is absolute so the startup log names the directory that is
+// actually used. An existing /tmp/syslinux-downloads tree is not read or moved.
+func ResolveSyslinuxCacheDir(dbPath, configured string) (string, error) {
+ configured = strings.TrimSpace(configured)
+ if configured == "" {
+ dbPath = strings.TrimSpace(dbPath)
+ if dbPath == "" {
+ dbPath = "."
+ }
+ configured = filepath.Join(dbPath, SyslinuxCacheDirName)
+ }
+ if err := os.MkdirAll(configured, 0o755); err != nil {
+ return "", fmt.Errorf("create Syslinux cache %s: %w", configured, err)
+ }
+ abs, err := filepath.Abs(configured)
+ if err != nil {
+ return configured, nil
+ }
+ return abs, nil
+}
diff --git a/config/syslinux_cache_test.go b/config/syslinux_cache_test.go
new file mode 100644
index 0000000..73b3e86
--- /dev/null
+++ b/config/syslinux_cache_test.go
@@ -0,0 +1,49 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveSyslinuxCacheDirDefault(t *testing.T) {
+ dbPath := t.TempDir()
+ got, err := ResolveSyslinuxCacheDir(dbPath, "")
+ require.NoError(t, err)
+ want, err := filepath.Abs(filepath.Join(dbPath, SyslinuxCacheDirName))
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+ info, err := os.Stat(got)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
+
+func TestResolveSyslinuxCacheDirOverride(t *testing.T) {
+ dbPath := t.TempDir()
+ override := filepath.Join(t.TempDir(), "custom-cache")
+ got, err := ResolveSyslinuxCacheDir(dbPath, override)
+ require.NoError(t, err)
+ want, err := filepath.Abs(override)
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+ require.NotEqual(t, filepath.Join(dbPath, SyslinuxCacheDirName), override)
+ info, err := os.Stat(got)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+ _, err = os.Stat(filepath.Join(dbPath, SyslinuxCacheDirName))
+ require.True(t, os.IsNotExist(err))
+}
+
+func TestResolveSyslinuxCacheDirTrimsAndCreatesNested(t *testing.T) {
+ nested := filepath.Join(t.TempDir(), "a", "b")
+ got, err := ResolveSyslinuxCacheDir(t.TempDir(), " "+nested+" ")
+ require.NoError(t, err)
+ want, err := filepath.Abs(nested)
+ require.NoError(t, err)
+ require.Equal(t, want, got)
+ info, err := os.Stat(got)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
diff --git a/config/tftp_root.go b/config/tftp_root.go
new file mode 100644
index 0000000..5dc80b0
--- /dev/null
+++ b/config/tftp_root.go
@@ -0,0 +1,94 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// DefaultTFTPDir is the TFTP root when TFTP_DIR is unset.
+// The release image overrides it with TFTP_DIR=/app/public/tftp.
+const DefaultTFTPDir = "./public/tftp"
+
+// TFTPRoot is the directory the TFTP server will serve.
+type TFTPRoot struct {
+ // Path is the absolute directory the server reads and writes.
+ Path string
+ // Display is the path shown on the TFTP page. It is the configured
+ // TFTP_DIR string as given, or DefaultTFTPDir when that is unset.
+ // When the configured directory cannot be created, Display is the
+ // temporary directory that is actually served.
+ Display string
+ // Ephemeral is true when Path will not survive a process restart.
+ Ephemeral bool
+ // Warning is empty when Path is a normal persistent directory.
+ Warning string
+}
+
+// ResolveTFTPRoot chooses the TFTP directory.
+// A non-empty configured path (the TFTP_DIR override) is kept. An empty
+// path uses DefaultTFTPDir. The directory is created. When it cannot be
+// created, a temporary directory is used instead. A path under the system
+// temp directory is served as given, and reported as ephemeral.
+func ResolveTFTPRoot(configured string) (TFTPRoot, error) {
+ configured = strings.TrimSpace(configured)
+ if configured == "" {
+ configured = DefaultTFTPDir
+ }
+ display := configured
+ if err := os.MkdirAll(configured, 0o755); err != nil {
+ fallback, ferr := os.MkdirTemp("", "ignite-tftp")
+ if ferr != nil {
+ return TFTPRoot{}, fmt.Errorf("create TFTP directory %s: %w", configured, err)
+ }
+ return TFTPRoot{
+ Path: fallback,
+ Display: fallback,
+ Ephemeral: true,
+ Warning: fmt.Sprintf(
+ "WARNING: TFTP directory %q could not be created (%v). Falling back to temporary directory %s. Boot files, OS images, and per-MAC configs in this directory will not survive a restart",
+ configured, err, fallback,
+ ),
+ }, nil
+ }
+ abs, err := filepath.Abs(configured)
+ if err != nil {
+ abs = configured
+ }
+ root := TFTPRoot{Path: abs, Display: display}
+ if ephemeralPath(abs) {
+ root.Ephemeral = true
+ root.Warning = fmt.Sprintf(
+ "WARNING: TFTP root %s is under the system temp directory. Boot files, OS images, and per-MAC configs will not survive a restart",
+ abs,
+ )
+ }
+ return root, nil
+}
+
+// StartupLines is the log text for the resolved root.
+// The warning line is included only when the directory is ephemeral.
+func (r TFTPRoot) StartupLines() []string {
+ lines := []string{"TFTP root: " + r.Path}
+ if r.Warning != "" {
+ lines = append(lines, r.Warning)
+ }
+ return lines
+}
+
+func ephemeralPath(path string) bool {
+ temp, err := filepath.Abs(os.TempDir())
+ if err != nil {
+ temp = os.TempDir()
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ abs = path
+ }
+ rel, err := filepath.Rel(filepath.Clean(temp), filepath.Clean(abs))
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
diff --git a/config/tftp_root_test.go b/config/tftp_root_test.go
new file mode 100644
index 0000000..25960f4
--- /dev/null
+++ b/config/tftp_root_test.go
@@ -0,0 +1,127 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveTFTPRootOverride(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "outside")
+ // t.TempDir is under the system temp dir, so use a directory we create
+ // beside the module only when it is not itself temporary. The override
+ // test below uses a path under TempDir on purpose for the warning case.
+ parent := t.TempDir()
+ if ephemeralPath(parent) {
+ dir = filepath.Join(parent, "tftp")
+ }
+ root, err := ResolveTFTPRoot(dir)
+ require.NoError(t, err)
+ abs, err := filepath.Abs(dir)
+ require.NoError(t, err)
+ require.Equal(t, abs, root.Path)
+ info, err := os.Stat(root.Path)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+ if ephemeralPath(abs) {
+ require.True(t, root.Ephemeral)
+ require.Contains(t, root.Warning, "will not survive a restart")
+ require.NotContains(t, root.Warning, "Falling back")
+ }
+ text := strings.Join(root.StartupLines(), "\n")
+ require.Contains(t, text, "TFTP root: "+abs)
+}
+
+func TestResolveTFTPRootKeepsPersistentOverride(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "custom-tftp")
+ root, err := ResolveTFTPRoot(dir)
+ require.NoError(t, err)
+
+ abs, err := filepath.Abs(dir)
+ require.NoError(t, err)
+ require.Equal(t, abs, root.Path)
+ require.NotContains(t, root.Path, "ignite-tftp")
+ require.NotContains(t, root.Warning, "Falling back")
+ info, err := os.Stat(root.Path)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
+
+func TestResolveTFTPRootDefaultStaysPublicTFTP(t *testing.T) {
+ work := t.TempDir()
+ prev, err := os.Getwd()
+ require.NoError(t, err)
+ require.NoError(t, os.Chdir(work))
+ t.Cleanup(func() { _ = os.Chdir(prev) })
+
+ root, err := ResolveTFTPRoot("")
+ require.NoError(t, err)
+ want, err := filepath.Abs(DefaultTFTPDir)
+ require.NoError(t, err)
+ require.Equal(t, want, root.Path)
+ require.Equal(t, DefaultTFTPDir, root.Display)
+ require.NotContains(t, root.Path, "ignite-tftp")
+ require.NotContains(t, root.Warning, "Falling back")
+ _, err = os.Stat(filepath.Join(work, "public", "tftp"))
+ require.NoError(t, err)
+}
+
+func TestResolveTFTPRootFallsBackWhenCreateFails(t *testing.T) {
+ blocked := filepath.Join(t.TempDir(), "not-a-directory")
+ require.NoError(t, os.WriteFile(blocked, []byte("x"), 0o644))
+ configured := filepath.Join(blocked, "tftp")
+
+ root, err := ResolveTFTPRoot(configured)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.RemoveAll(root.Path) })
+ require.True(t, root.Ephemeral)
+ require.Contains(t, root.Path, "ignite-tftp")
+ require.Equal(t, root.Path, root.Display)
+ require.NotEqual(t, configured, root.Path)
+ require.NotEqual(t, configured, root.Display)
+ require.Contains(t, root.Warning, "WARNING:")
+ require.Contains(t, root.Warning, "will not survive a restart")
+ require.Contains(t, strings.Join(root.StartupLines(), "\n"), root.Warning)
+ info, err := os.Stat(root.Path)
+ require.NoError(t, err)
+ require.True(t, info.IsDir())
+}
+
+func TestResolveTFTPRootDisplayKeepsConfiguredString(t *testing.T) {
+ work := t.TempDir()
+ prev, err := os.Getwd()
+ require.NoError(t, err)
+ require.NoError(t, os.Chdir(work))
+ t.Cleanup(func() { _ = os.Chdir(prev) })
+
+ root, err := ResolveTFTPRoot("./public/tftp")
+ require.NoError(t, err)
+ require.Equal(t, "./public/tftp", root.Display)
+ abs, err := filepath.Abs("./public/tftp")
+ require.NoError(t, err)
+ require.Equal(t, abs, root.Path)
+ require.NotEqual(t, root.Display, root.Path)
+
+ absolute := filepath.Join(t.TempDir(), "app", "public", "tftp")
+ root, err = ResolveTFTPRoot(absolute)
+ require.NoError(t, err)
+ require.Equal(t, absolute, root.Display)
+ abs, err = filepath.Abs(absolute)
+ require.NoError(t, err)
+ require.Equal(t, abs, root.Path)
+}
+
+func TestResolveTFTPRootWarnsForTempOverride(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "ignite-tftp.UPoTst")
+ root, err := ResolveTFTPRoot(dir)
+ require.NoError(t, err)
+ abs, err := filepath.Abs(dir)
+ require.NoError(t, err)
+ require.Equal(t, abs, root.Path)
+ require.True(t, root.Ephemeral)
+ require.Contains(t, root.Warning, "system temp directory")
+ require.Contains(t, root.Warning, "will not survive a restart")
+}
diff --git a/db/bolt.go b/db/bolt.go
index d63e1a7..8881626 100644
--- a/db/bolt.go
+++ b/db/bolt.go
@@ -2,6 +2,7 @@ package db
import (
"context"
+ "errors"
"fmt"
"ignite/config"
"path/filepath"
@@ -147,6 +148,74 @@ func (b *BoltDB) DeleteAllKV(ctx context.Context, bucket string) error {
})
}
+// boltKVTx adapts a bbolt transaction to Tx.
+type boltKVTx struct {
+ tx *bolt.Tx
+}
+
+func (t *boltKVTx) Get(bucket string, key []byte) ([]byte, error) {
+ bkt := t.tx.Bucket([]byte(bucket))
+ if bkt == nil {
+ return nil, nil
+ }
+ data := bkt.Get(key)
+ if data == nil {
+ return nil, nil
+ }
+ value := make([]byte, len(data))
+ copy(value, data)
+ return value, nil
+}
+
+func (t *boltKVTx) Put(bucket string, key, value []byte) error {
+ bkt, err := t.tx.CreateBucketIfNotExists([]byte(bucket))
+ if err != nil {
+ return fmt.Errorf("create bucket: %w", err)
+ }
+ return bkt.Put(key, value)
+}
+
+func (t *boltKVTx) Delete(bucket string, key []byte) error {
+ bkt := t.tx.Bucket([]byte(bucket))
+ if bkt == nil {
+ return nil
+ }
+ return bkt.Delete(key)
+}
+
+// ForEach walks bucket. Mutating the same bucket from fn is undefined in
+// bbolt, so callers collect matches and write after ForEach returns.
+// ErrStopIteration ends the walk without failing the transaction.
+func (t *boltKVTx) ForEach(bucket string, fn func(k, v []byte) error) error {
+ if fn == nil {
+ return fmt.Errorf("foreach function is nil")
+ }
+ bkt := t.tx.Bucket([]byte(bucket))
+ if bkt == nil {
+ return nil
+ }
+ err := bkt.ForEach(func(k, v []byte) error {
+ return fn(k, v)
+ })
+ if errors.Is(err, ErrStopIteration) {
+ return nil
+ }
+ return err
+}
+
+// Update runs fn inside one read-write transaction.
+func (b *BoltDB) Update(ctx context.Context, fn func(Tx) error) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if fn == nil {
+ return fmt.Errorf("transaction function is nil")
+ }
+ return b.db.Update(func(tx *bolt.Tx) error {
+ return fn(&boltKVTx{tx: tx})
+ })
+}
+
// BatchPut stores multiple key-value pairs in a single bucket atomically,
// i.e. inside one bbolt read-write transaction.
func (b *BoltDB) BatchPut(ctx context.Context, bucket string, kvs map[string][]byte) error {
diff --git a/db/interfaces.go b/db/interfaces.go
index da5d8a5..1cc9c7d 100644
--- a/db/interfaces.go
+++ b/db/interfaces.go
@@ -9,6 +9,23 @@ import (
// Use errors.Is to distinguish it from real backend failures.
var ErrNotFound = errors.New("entity not found")
+// ErrStopIteration stops Tx.ForEach without failing or rolling back the
+// surrounding transaction.
+var ErrStopIteration = errors.New("stop iteration")
+
+// Tx is a single read-write transaction. Values returned by Get are copies
+// and stay valid after the transaction ends. Put creates a missing bucket.
+// Delete of a missing bucket or key is a no-op.
+type Tx interface {
+ Get(bucket string, key []byte) ([]byte, error)
+ Put(bucket string, key, value []byte) error
+ Delete(bucket string, key []byte) error
+ // ForEach visits every key in bucket. A missing bucket is an empty
+ // range, not an error. Returning ErrStopIteration from fn stops the
+ // walk and ForEach itself returns nil.
+ ForEach(bucket string, fn func(k, v []byte) error) error
+}
+
// Database defines the interface for database operations
type Database interface {
Close() error
@@ -18,6 +35,9 @@ type Database interface {
GetAllKV(ctx context.Context, bucket string) (map[string][]byte, error)
DeleteAllKV(ctx context.Context, bucket string) error
GetOrCreateBucket(ctx context.Context, name string) error
+ // Update runs fn inside one read-write transaction. Returning an error
+ // from fn rolls the whole transaction back.
+ Update(ctx context.Context, fn func(Tx) error) error
}
// Repository provides a generic repository interface
diff --git a/db/tx_test.go b/db/tx_test.go
new file mode 100644
index 0000000..beaf3a4
--- /dev/null
+++ b/db/tx_test.go
@@ -0,0 +1,106 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "ignite/config"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestBoltDB_UpdateRollsBack(t *testing.T) {
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(t.TempDir()).
+ WithDBFile("tx.db").
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+
+ ctx := context.Background()
+ boom := errors.New("boom")
+ err = database.Update(ctx, func(tx Tx) error {
+ if err := tx.Put("letters", []byte("a"), []byte("1")); err != nil {
+ return err
+ }
+ return boom
+ })
+ require.ErrorIs(t, err, boom)
+
+ got, err := database.GetKV(ctx, "letters", []byte("a"))
+ if err != nil {
+ require.Contains(t, err.Error(), "not found")
+ return
+ }
+ require.Nil(t, got, "a failed transaction must not leave the key behind")
+}
+
+func TestBoltDB_UpdateCommitsBothBuckets(t *testing.T) {
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(t.TempDir()).
+ WithDBFile("tx.db").
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+
+ ctx := context.Background()
+ err = database.Update(ctx, func(tx Tx) error {
+ if err := tx.Put("rows", []byte("id"), []byte("row")); err != nil {
+ return err
+ }
+ return tx.Put("index", []byte("mac"), []byte("id"))
+ })
+ require.NoError(t, err)
+
+ row, err := database.GetKV(ctx, "rows", []byte("id"))
+ require.NoError(t, err)
+ require.Equal(t, "row", string(row))
+ id, err := database.GetKV(ctx, "index", []byte("mac"))
+ require.NoError(t, err)
+ require.Equal(t, "id", string(id))
+}
+
+func TestBoltDB_ForEachStopDoesNotRollBack(t *testing.T) {
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(t.TempDir()).
+ WithDBFile("tx.db").
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+
+ ctx := context.Background()
+ err = database.Update(ctx, func(tx Tx) error {
+ if err := tx.Put("rows", []byte("a"), []byte("1")); err != nil {
+ return err
+ }
+ if err := tx.Put("rows", []byte("b"), []byte("2")); err != nil {
+ return err
+ }
+ n := 0
+ if err := tx.ForEach("rows", func(k, v []byte) error {
+ n++
+ return ErrStopIteration
+ }); err != nil {
+ return err
+ }
+ if n != 1 {
+ return errors.New("foreach did not stop")
+ }
+ return tx.Put("rows", []byte("c"), []byte("3"))
+ })
+ require.NoError(t, err)
+
+ got, err := database.GetKV(ctx, "rows", []byte("c"))
+ require.NoError(t, err)
+ require.Equal(t, "3", string(got))
+}
diff --git a/dhcp/disabled_test.go b/dhcp/disabled_test.go
new file mode 100644
index 0000000..517f75b
--- /dev/null
+++ b/dhcp/disabled_test.go
@@ -0,0 +1,45 @@
+package dhcp
+
+import (
+ "context"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestStartServerWhenDHCPDisabledDoesNotBind(t *testing.T) {
+ t.Setenv("DHCP_ENABLED", "false")
+ repo := &MockServerRepository{}
+ repo.On("Get", mock.Anything, "srv").Return(&Server{
+ ID: "srv",
+ IP: net.ParseIP("127.0.0.1"),
+ }, nil)
+ svc := NewDHCPServerService(repo, &MockLeaseRepository{})
+
+ err := svc.StartServer(context.Background(), "srv")
+ require.ErrorIs(t, err, ErrDHCPDisabled)
+ repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
+ require.Equal(t, ServerStateConfigured, CurrentServerState("srv"))
+}
+
+func TestDisabledListenersStayConfigured(t *testing.T) {
+ const id = "stopped-while-off"
+ SetListenersEnabled(false)
+ t.Cleanup(func() {
+ SetListenersEnabled(true)
+ ClearServerState(id)
+ })
+ NoteServerState(id, ServerStateStopped)
+ NoteServerState(id+"-run", ServerStateRunning)
+ t.Cleanup(func() { ClearServerState(id + "-run") })
+
+ require.Equal(t, ServerStateConfigured, CurrentServerState(id))
+ require.Equal(t, ServerStateConfigured, CurrentServerState(id+"-run"))
+ require.False(t, ListenersEnabled())
+
+ SetListenersEnabled(true)
+ require.Equal(t, ServerStateStopped, CurrentServerState(id))
+ require.Equal(t, ServerStateRunning, CurrentServerState(id+"-run"))
+}
diff --git a/dhcp/lease_concurrency_test.go b/dhcp/lease_concurrency_test.go
new file mode 100644
index 0000000..8c0b327
--- /dev/null
+++ b/dhcp/lease_concurrency_test.go
@@ -0,0 +1,281 @@
+package dhcp
+
+import (
+ "context"
+ "net"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "ignite/db"
+
+ "github.com/stretchr/testify/require"
+)
+
+// gateRepo is an in-memory lease repository. When armed, the next GetByMAC
+// blocks until release is closed, so a test can prove a mutation still holds
+// the allocation lock across the read.
+type gateRepo struct {
+ mu sync.Mutex
+ leases map[string]*Lease
+ byMAC map[string]string
+ arm atomic.Bool
+ entered chan struct{}
+ release chan struct{}
+ unblock sync.Once
+}
+
+func newGateRepo() *gateRepo {
+ return &gateRepo{
+ leases: map[string]*Lease{},
+ byMAC: map[string]string{},
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+}
+
+func (r *gateRepo) open() {
+ r.unblock.Do(func() { close(r.release) })
+}
+
+func (r *gateRepo) clone(lease *Lease) *Lease {
+ if lease == nil {
+ return nil
+ }
+ cp := *lease
+ if lease.IP != nil {
+ cp.IP = append(net.IP(nil), lease.IP...)
+ }
+ if lease.StateHistory != nil {
+ cp.StateHistory = append([]StateTransition(nil), lease.StateHistory...)
+ }
+ return &cp
+}
+
+func (r *gateRepo) Save(ctx context.Context, lease *Lease) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.leases[lease.ID] = r.clone(lease)
+ r.byMAC[lease.MAC] = lease.ID
+ return nil
+}
+
+func (r *gateRepo) Get(ctx context.Context, id string) (*Lease, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ lease, ok := r.leases[id]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ return r.clone(lease), nil
+}
+
+func (r *gateRepo) GetByMAC(ctx context.Context, mac string) (*Lease, error) {
+ if r.arm.CompareAndSwap(true, false) {
+ close(r.entered)
+ <-r.release
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ id, ok := r.byMAC[mac]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ return r.clone(r.leases[id]), nil
+}
+
+func (r *gateRepo) GetByServerID(ctx context.Context, serverID string) ([]*Lease, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ var out []*Lease
+ for _, lease := range r.leases {
+ if lease.ServerID == serverID {
+ out = append(out, r.clone(lease))
+ }
+ }
+ return out, nil
+}
+
+func (r *gateRepo) Delete(ctx context.Context, id string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if lease, ok := r.leases[id]; ok {
+ delete(r.byMAC, lease.MAC)
+ delete(r.leases, id)
+ }
+ return nil
+}
+
+func (r *gateRepo) DeleteByMAC(ctx context.Context, mac string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ id, ok := r.byMAC[mac]
+ if !ok {
+ return db.ErrNotFound
+ }
+ delete(r.leases, id)
+ delete(r.byMAC, mac)
+ return nil
+}
+
+func (r *gateRepo) DeleteByServerID(ctx context.Context, serverID string) error { return nil }
+func (r *gateRepo) GetExpired(ctx context.Context) ([]*Lease, error) { return nil, nil }
+func (r *gateRepo) CleanupExpired(ctx context.Context) error { return nil }
+
+type memServers struct {
+ server *Server
+}
+
+func (m memServers) Save(ctx context.Context, server *Server) error { return nil }
+func (m memServers) Get(ctx context.Context, id string) (*Server, error) {
+ if m.server == nil || m.server.ID != id {
+ return nil, db.ErrNotFound
+ }
+ return m.server, nil
+}
+func (m memServers) GetAll(ctx context.Context) ([]*Server, error) { return []*Server{m.server}, nil }
+func (m memServers) Delete(ctx context.Context, id string) error { return nil }
+func (m memServers) GetByIP(ctx context.Context, ip net.IP) (*Server, error) {
+ return nil, db.ErrNotFound
+}
+
+func TestUpdateLeaseStateDoesNotClobberRenewal(t *testing.T) {
+ ctx := context.Background()
+ repo := newGateRepo()
+ server := &Server{
+ ID: "srv",
+ IP: net.ParseIP("192.168.1.1"),
+ IPStart: net.ParseIP("192.168.1.100"),
+ LeaseRange: 20,
+ LeaseDuration: 2 * time.Hour,
+ }
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ mac := "aa:bb:cc:dd:ee:10"
+ ip := net.ParseIP("192.168.1.100")
+ first, err := svc.AssignLease(ctx, server.ID, mac, ip)
+ require.NoError(t, err)
+ originalExpiry := first.Expiry
+
+ repo.arm.Store(true)
+ defer repo.open()
+ stateDone := make(chan error, 1)
+ go func() {
+ stateDone <- svc.UpdateLeaseState(ctx, mac, string(StateFailed), "provision")
+ }()
+
+ select {
+ case <-repo.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("state update did not reach its lease read")
+ }
+
+ renewDone := make(chan error, 1)
+ go func() {
+ _, err := svc.AssignLease(ctx, server.ID, mac, ip)
+ renewDone <- err
+ }()
+
+ select {
+ case err := <-renewDone:
+ t.Fatalf("renewal finished while the state update was inside its read: %v", err)
+ case <-time.After(150 * time.Millisecond):
+ // The renewal is waiting on the allocation lock. Release the read.
+ }
+
+ repo.open()
+
+ require.NoError(t, <-stateDone)
+ require.NoError(t, <-renewDone)
+
+ got, err := svc.GetLeaseByMAC(ctx, mac)
+ require.NoError(t, err)
+ require.Equal(t, StateFailed, got.State, "provisioning state must survive the renewal")
+ require.True(t, got.Expiry.After(originalExpiry), "renewal expiry must survive the state update, got %s not after %s", got.Expiry, originalExpiry)
+}
+
+func TestUpdateLeaseKeepsNewerExpiry(t *testing.T) {
+ ctx := context.Background()
+ repo := newGateRepo()
+ server := &Server{
+ ID: "srv",
+ IP: net.ParseIP("192.168.1.1"),
+ IPStart: net.ParseIP("192.168.1.100"),
+ LeaseRange: 20,
+ LeaseDuration: 2 * time.Hour,
+ }
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ mac := "aa:bb:cc:dd:ee:11"
+ ip := net.ParseIP("192.168.1.110")
+ created, err := svc.AssignLease(ctx, server.ID, mac, ip)
+ require.NoError(t, err)
+
+ stale, err := svc.GetLeaseByMAC(ctx, mac)
+ require.NoError(t, err)
+ stale.Menu.Hostname = "build-box"
+
+ // A renewal lands after the caller took its snapshot.
+ renewedExpiry := created.Expiry.Add(time.Hour)
+ stored, err := repo.Get(ctx, created.ID)
+ require.NoError(t, err)
+ stored.Expiry = renewedExpiry
+ require.NoError(t, repo.Save(ctx, stored))
+
+ require.NoError(t, svc.UpdateLease(ctx, stale))
+
+ got, err := svc.GetLeaseByMAC(ctx, mac)
+ require.NoError(t, err)
+ require.Equal(t, "build-box", got.Menu.Hostname)
+ require.True(t, got.Expiry.Equal(renewedExpiry), "stale save rolled the renewal back to %s", got.Expiry)
+}
+
+func TestRecordHeartbeatDoesNotRollBackExpiry(t *testing.T) {
+ ctx := context.Background()
+ repo := newGateRepo()
+ server := &Server{
+ ID: "srv",
+ IP: net.ParseIP("192.168.1.1"),
+ IPStart: net.ParseIP("192.168.1.100"),
+ LeaseRange: 20,
+ LeaseDuration: 2 * time.Hour,
+ }
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ mac := "aa:bb:cc:dd:ee:12"
+ ip := net.ParseIP("192.168.1.120")
+ created, err := svc.AssignLease(ctx, server.ID, mac, ip)
+ require.NoError(t, err)
+
+ repo.arm.Store(true)
+ defer repo.open()
+ beatDone := make(chan error, 1)
+ go func() {
+ beatDone <- svc.RecordHeartbeat(ctx, mac)
+ }()
+ select {
+ case <-repo.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("heartbeat did not reach its lease read")
+ }
+
+ renewDone := make(chan error, 1)
+ go func() {
+ _, err := svc.AssignLease(ctx, server.ID, mac, ip)
+ renewDone <- err
+ }()
+ select {
+ case err := <-renewDone:
+ t.Fatalf("renewal finished while the heartbeat was inside its read: %v", err)
+ case <-time.After(150 * time.Millisecond):
+ }
+ repo.open()
+ require.NoError(t, <-beatDone)
+ require.NoError(t, <-renewDone)
+
+ got, err := svc.GetLeaseByMAC(ctx, mac)
+ require.NoError(t, err)
+ require.True(t, got.Expiry.After(created.Expiry), "heartbeat saved a stale expiry")
+ require.False(t, got.LastSeen.IsZero())
+}
diff --git a/dhcp/lease_service.go b/dhcp/lease_service.go
index 8c19060..cee3715 100644
--- a/dhcp/lease_service.go
+++ b/dhcp/lease_service.go
@@ -38,13 +38,23 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
s.allocMu.Lock()
defer s.allocMu.Unlock()
+ if canon, err := CanonicalMAC(mac); err == nil {
+ mac = canon
+ }
+
server, err := s.serverRepo.Get(ctx, serverID)
if err != nil {
return nil, fmt.Errorf("failed to get server: %w", err)
}
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return nil, err
+ }
+ defer unlock()
+
// Check if MAC already has a lease
- existingLease, err := s.leaseRepo.GetByMAC(ctx, mac)
+ existingLease, err := s.leaseByMAC(ctx, mac)
if err == nil {
// An existing lease may only be extended when it belongs to the
// requested server; a lease from another server is not ours to renew.
@@ -52,8 +62,15 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
return nil, fmt.Errorf("MAC %s already has a lease on another server", mac)
}
if !existingLease.IsExpired() {
- // Extend existing lease if not expired
+ // Extend existing lease if not expired. A renew is activity:
+ // it clears offline and restores the previous stage. Keep the
+ // host token; fill one in when an older row lacks it.
+ existingLease.MAC = mac
existingLease.Extend(server.LeaseDuration)
+ existingLease.NoteActivity("dhcp")
+ if err := ensureProvisionToken(existingLease); err != nil {
+ return nil, err
+ }
if err := s.leaseRepo.Save(ctx, existingLease); err != nil {
return nil, fmt.Errorf("failed to extend existing lease: %w", err)
}
@@ -61,7 +78,7 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
}
// The old lease is expired: delete it before creating its replacement.
// A not-found here just means someone else already removed it.
- if err := s.leaseRepo.DeleteByMAC(ctx, mac); err != nil && !errors.Is(err, db.ErrNotFound) {
+ if err := s.leaseRepo.DeleteByMAC(ctx, existingLease.MAC); err != nil && !errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("failed to delete expired lease: %w", err)
}
} else if !errors.Is(err, db.ErrNotFound) {
@@ -101,6 +118,9 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
{FromState: "", ToState: StateAssigned, Timestamp: now, Source: "dhcp"},
},
}
+ if err := ensureProvisionToken(lease); err != nil {
+ return nil, err
+ }
// Save lease
if err := s.leaseRepo.Save(ctx, lease); err != nil {
@@ -110,8 +130,23 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
return lease, nil
}
-// ReleaseLease releases a lease by MAC address
+// ReleaseLease releases a lease by MAC address.
+// The MAC is canonicalized so a legacy spelling still finds the row.
func (s *DHCPLeaseService) ReleaseLease(ctx context.Context, mac string) error {
+ if canon, err := CanonicalMAC(mac); err == nil {
+ mac = canon
+ }
+ s.allocMu.Lock()
+ defer s.allocMu.Unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
if err := s.leaseRepo.DeleteByMAC(ctx, mac); err != nil {
return fmt.Errorf("failed to release lease: %w", err)
}
@@ -120,6 +155,18 @@ func (s *DHCPLeaseService) ReleaseLease(ctx context.Context, mac string) error {
// ReserveLease reserves a specific IP for a MAC address
func (s *DHCPLeaseService) ReserveLease(ctx context.Context, serverID string, mac string, ip net.IP) error {
+ s.allocMu.Lock()
+ defer s.allocMu.Unlock()
+
+ if canon, err := CanonicalMAC(mac); err == nil {
+ mac = canon
+ }
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
server, err := s.serverRepo.Get(ctx, serverID)
if err != nil {
return fmt.Errorf("failed to get server: %w", err)
@@ -156,6 +203,9 @@ func (s *DHCPLeaseService) ReserveLease(ctx context.Context, serverID string, ma
{FromState: "", ToState: StateAssigned, Timestamp: now, Source: "manual"},
},
}
+ if err := ensureProvisionToken(lease); err != nil {
+ return err
+ }
if err := s.leaseRepo.Save(ctx, lease); err != nil {
return fmt.Errorf("failed to save reserved lease: %w", err)
@@ -164,9 +214,17 @@ func (s *DHCPLeaseService) ReserveLease(ctx context.Context, serverID string, ma
return nil
}
-// UnreserveLease removes a reservation by MAC address
+// UnreserveLease removes a reservation by MAC address.
+// This is a single-lease update, so it takes the per-MAC lock and re-reads
+// inside it. It does not hold the pool allocation lock.
func (s *DHCPLeaseService) UnreserveLease(ctx context.Context, mac string) error {
- lease, err := s.leaseRepo.GetByMAC(ctx, mac)
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ lease, err := s.leaseByMAC(ctx, mac)
if err != nil {
return fmt.Errorf("failed to get lease: %w", err)
}
@@ -179,9 +237,10 @@ func (s *DHCPLeaseService) UnreserveLease(ctx context.Context, mac string) error
return nil
}
-// GetLeaseByMAC retrieves a lease by MAC address
+// GetLeaseByMAC retrieves a lease by MAC address.
+// The lookup accepts any conventional MAC spelling.
func (s *DHCPLeaseService) GetLeaseByMAC(ctx context.Context, mac string) (*Lease, error) {
- return s.leaseRepo.GetByMAC(ctx, mac)
+ return s.leaseByMAC(ctx, mac)
}
// GetLeasesByServer retrieves all leases for a server
@@ -189,27 +248,108 @@ func (s *DHCPLeaseService) GetLeasesByServer(ctx context.Context, serverID strin
return s.leaseRepo.GetByServerID(ctx, serverID)
}
-// CleanupExpiredLeases removes expired leases
+// CleanupExpiredLeases removes expired leases.
+// The scan does not hold the allocation lock. Each delete takes the per-MAC
+// lock, re-reads the row, and skips it when a renew extended it. A cancelled
+// ctx stops the loop before the next delete.
func (s *DHCPLeaseService) CleanupExpiredLeases(ctx context.Context) error {
- return s.leaseRepo.CleanupExpired(ctx)
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ expired, err := s.leaseRepo.GetExpired(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get expired leases: %w", err)
+ }
+ for _, lease := range expired {
+ if err := s.deleteIfExpired(ctx, lease); err != nil {
+ return err
+ }
+ }
+ return nil
}
-// UpdateLease updates an existing lease
+// deleteIfExpired removes lease when it is still expired under the per-MAC lock.
+func (s *DHCPLeaseService) deleteIfExpired(ctx context.Context, lease *Lease) error {
+ if lease == nil {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ unlock, err := lockLease(ctx, lease.MAC)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ fresh, err := s.leaseRepo.Get(ctx, lease.ID)
+ if errors.Is(err, db.ErrNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if fresh.Reserved || !time.Now().After(fresh.Expiry) {
+ return nil
+ }
+ if err := s.leaseRepo.Delete(ctx, fresh.ID); err != nil {
+ return fmt.Errorf("failed to delete expired lease %s: %w", fresh.ID, err)
+ }
+ return nil
+}
+
+// UpdateLease saves a caller-modified lease.
+// The read of the stored row and the write share the per-MAC lock.
+// DHCP-owned fields that moved forward after the caller read the lease
+// (expiry, last seen, provisioning state) are kept, so a stale copy cannot
+// roll back a concurrent renewal. A missing row is not recreated.
func (s *DHCPLeaseService) UpdateLease(ctx context.Context, lease *Lease) error {
+ if lease == nil {
+ return fmt.Errorf("lease is nil")
+ }
+ unlock, err := lockLease(ctx, lease.MAC)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ fresh, err := s.leaseRepo.Get(ctx, lease.ID)
+ if err != nil {
+ if errors.Is(err, db.ErrNotFound) {
+ return err
+ }
+ return fmt.Errorf("failed to read lease: %w", err)
+ }
+ preserveNewerDHCPFields(lease, fresh)
+ if err := ensureProvisionToken(lease); err != nil {
+ return err
+ }
if err := s.leaseRepo.Save(ctx, lease); err != nil {
return fmt.Errorf("failed to update lease: %w", err)
}
return nil
}
-// UpdateLeaseState updates the state of a lease with state tracking
+// UpdateLeaseState updates the state of a lease with state tracking.
+// The read-modify-write holds the per-MAC lock and re-reads inside it.
func (s *DHCPLeaseService) UpdateLeaseState(ctx context.Context, mac string, newState string, source string) error {
- lease, err := s.leaseRepo.GetByMAC(ctx, mac)
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ lease, err := s.leaseByMAC(ctx, mac)
if err != nil {
return fmt.Errorf("failed to get lease: %w", err)
}
lease.UpdateState(LeaseState(newState), source)
+ if err := ensureProvisionToken(lease); err != nil {
+ return err
+ }
if err := s.leaseRepo.Save(ctx, lease); err != nil {
return fmt.Errorf("failed to save lease state: %w", err)
}
@@ -218,16 +358,30 @@ func (s *DHCPLeaseService) UpdateLeaseState(ctx context.Context, mac string, new
}
// RecordHeartbeat records a heartbeat from a machine, updating LastSeen
+// and LastHeartbeat. An offline lease returns to its last non-offline
+// stage. The read-modify-write holds the per-MAC lock so it cannot clobber
+// a DHCP renewal that is extending the same row.
func (s *DHCPLeaseService) RecordHeartbeat(ctx context.Context, mac string) error {
- lease, err := s.leaseRepo.GetByMAC(ctx, mac)
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ lease, err := s.leaseByMAC(ctx, mac)
if err != nil {
return fmt.Errorf("failed to get lease: %w", err)
}
- lease.LastSeen = time.Now()
- // If lease was offline and now checking in, mark it as needing attention
+ now := time.Now()
+ lease.LastHeartbeat = now
if lease.State == StateOffline {
- lease.UpdateState(StateAssigned, "heartbeat")
+ lease.RestoreFromOffline("heartbeat")
+ } else {
+ lease.LastSeen = now
+ }
+ if err := ensureProvisionToken(lease); err != nil {
+ return err
}
if err := s.leaseRepo.Save(ctx, lease); err != nil {
@@ -239,7 +393,7 @@ func (s *DHCPLeaseService) RecordHeartbeat(ctx context.Context, mac string) erro
// GetLeaseStateHistory returns the state transition history for a lease
func (s *DHCPLeaseService) GetLeaseStateHistory(ctx context.Context, mac string) ([]StateTransition, error) {
- lease, err := s.leaseRepo.GetByMAC(ctx, mac)
+ lease, err := s.leaseByMAC(ctx, mac)
if err != nil {
return nil, fmt.Errorf("failed to get lease: %w", err)
}
@@ -271,27 +425,34 @@ func (s *DHCPLeaseService) GetLeasesByState(ctx context.Context, state string) (
return result, nil
}
-// MarkOfflineLeases marks leases as offline if they haven't been seen recently
+// MarkOfflineLeases marks leases as offline when they have been quiet longer
+// than their OfflineWindow. offlineThreshold is the heartbeat floor; the
+// window also depends on the lease duration and whether the host has
+// heartbeated or is mid-install. The scan does not hold the allocation
+// lock. Each candidate is re-read under the per-MAC lock, and a cancelled
+// ctx stops the loop before the next write.
func (s *DHCPLeaseService) MarkOfflineLeases(ctx context.Context, offlineThreshold time.Duration) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
servers, err := s.serverRepo.GetAll(ctx)
if err != nil {
return fmt.Errorf("failed to get servers: %w", err)
}
- cutoffTime := time.Now().Add(-offlineThreshold)
-
for _, server := range servers {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
leases, err := s.leaseRepo.GetByServerID(ctx, server.ID)
if err != nil {
return fmt.Errorf("failed to get leases for server %s: %w", server.ID, err)
}
-
+ duration := server.LeaseDuration
for _, lease := range leases {
- if lease.IsActive() && lease.LastSeen.Before(cutoffTime) {
- lease.UpdateState(StateOffline, "heartbeat")
- if err := s.leaseRepo.Save(ctx, lease); err != nil {
- return fmt.Errorf("failed to mark lease %s offline: %w", lease.ID, err)
- }
+ if err := s.markOneOffline(ctx, lease, duration, offlineThreshold); err != nil {
+ return err
}
}
}
@@ -299,6 +460,43 @@ func (s *DHCPLeaseService) MarkOfflineLeases(ctx context.Context, offlineThresho
return nil
}
+// markOneOffline re-checks one lease under its lock and marks it offline
+// only when it is still quiet.
+func (s *DHCPLeaseService) markOneOffline(ctx context.Context, lease *Lease, leaseDuration, offlineThreshold time.Duration) error {
+ if lease == nil || !lease.IsActive() {
+ return nil
+ }
+ if !lease.LastSeen.Before(time.Now().Add(-lease.OfflineWindow(leaseDuration, offlineThreshold))) {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ unlock, err := lockLease(ctx, lease.MAC)
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ fresh, err := s.leaseByMAC(ctx, lease.MAC)
+ if errors.Is(err, db.ErrNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if !fresh.IsActive() || !fresh.LastSeen.Before(time.Now().Add(-fresh.OfflineWindow(leaseDuration, offlineThreshold))) {
+ return nil
+ }
+ fresh.UpdateState(StateOffline, "heartbeat")
+ if err := s.leaseRepo.Save(ctx, fresh); err != nil {
+ return fmt.Errorf("failed to mark lease %s offline: %w", fresh.ID, err)
+ }
+ return nil
+}
+
// isIPAvailable checks if an IP address is available for assignment
func (s *DHCPLeaseService) isIPAvailable(ctx context.Context, serverID string, ip net.IP, excludeMAC string) bool {
leases, err := s.leaseRepo.GetByServerID(ctx, serverID)
@@ -306,14 +504,60 @@ func (s *DHCPLeaseService) isIPAvailable(ctx context.Context, serverID string, i
return false
}
+ now := time.Now()
for _, lease := range leases {
- if lease.IP.Equal(ip) && lease.MAC != excludeMAC && !lease.IsExpired() {
+ if lease.IP.Equal(ip) && lease.MAC != excludeMAC && lease.OccupiesAddress(now) {
return false
}
}
return true
}
+// leaseByMAC loads a lease, accepting any conventional MAC spelling.
+// A canonical hit wins. When the row was stored under a different spelling,
+// the original string is tried so older rows remain reachable.
+func (s *DHCPLeaseService) leaseByMAC(ctx context.Context, mac string) (*Lease, error) {
+ if canon, err := CanonicalMAC(mac); err == nil {
+ lease, lerr := s.leaseRepo.GetByMAC(ctx, canon)
+ if lerr == nil {
+ return lease, nil
+ }
+ if !errors.Is(lerr, db.ErrNotFound) {
+ return nil, lerr
+ }
+ if canon == mac {
+ return nil, lerr
+ }
+ }
+ return s.leaseRepo.GetByMAC(ctx, mac)
+}
+
+// preserveNewerDHCPFields copies DHCP-owned fields from fresh onto incoming
+// when fresh is strictly newer. The caller's other edits (boot menu, IPMI,
+// reservation flag) stay in place.
+func preserveNewerDHCPFields(incoming, fresh *Lease) {
+ if incoming == nil || fresh == nil {
+ return
+ }
+ if fresh.Expiry.After(incoming.Expiry) {
+ incoming.Expiry = fresh.Expiry
+ }
+ if fresh.LastSeen.After(incoming.LastSeen) {
+ incoming.LastSeen = fresh.LastSeen
+ }
+ if fresh.StateUpdatedAt.After(incoming.StateUpdatedAt) {
+ incoming.State = fresh.State
+ incoming.StateUpdatedAt = fresh.StateUpdatedAt
+ incoming.StateHistory = append([]StateTransition(nil), fresh.StateHistory...)
+ }
+ if fresh.LastHeartbeat.After(incoming.LastHeartbeat) {
+ incoming.LastHeartbeat = fresh.LastHeartbeat
+ }
+ if incoming.ProvisionToken == "" {
+ incoming.ProvisionToken = fresh.ProvisionToken
+ }
+}
+
// findAvailableIP finds the next available IP in the server's range
func (s *DHCPLeaseService) findAvailableIP(ctx context.Context, serverID string, excludeMAC string) (net.IP, error) {
server, err := s.serverRepo.Get(ctx, serverID)
diff --git a/dhcp/locks.go b/dhcp/locks.go
new file mode 100644
index 0000000..1e3d2c8
--- /dev/null
+++ b/dhcp/locks.go
@@ -0,0 +1,53 @@
+package dhcp
+
+import (
+ "context"
+ "sync"
+)
+
+// leaseMu guards leaseLocks. A per-MAC mutex serializes read-modify-write of
+// one lease. Pool allocation still uses sharedAllocMu. The order is always
+// sharedAllocMu and then lockLease; a lease lock must never be held while
+// acquiring sharedAllocMu.
+var (
+ leaseMu sync.Mutex
+ leaseLocks = map[string]*sync.Mutex{}
+)
+
+// lockLease locks the canonical MAC and returns the unlock function.
+// The wait honors ctx so a cancelled maintenance pass does not sit on the
+// mutex until the holder finishes. The mutex is not re-entrant: callers that
+// already hold it must not call lockLease again.
+func lockLease(ctx context.Context, mac string) (func(), error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ key := mac
+ if canon, err := CanonicalMAC(mac); err == nil {
+ key = canon
+ }
+
+ leaseMu.Lock()
+ mu := leaseLocks[key]
+ if mu == nil {
+ mu = &sync.Mutex{}
+ leaseLocks[key] = mu
+ }
+ leaseMu.Unlock()
+
+ acquired := make(chan struct{})
+ go func() {
+ mu.Lock()
+ close(acquired)
+ }()
+ select {
+ case <-ctx.Done():
+ go func() {
+ <-acquired
+ mu.Unlock()
+ }()
+ return nil, ctx.Err()
+ case <-acquired:
+ return mu.Unlock, nil
+ }
+}
diff --git a/dhcp/maintenance.go b/dhcp/maintenance.go
new file mode 100644
index 0000000..2fd30a7
--- /dev/null
+++ b/dhcp/maintenance.go
@@ -0,0 +1,123 @@
+package dhcp
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+)
+
+const (
+ // DefaultMaintenanceInterval is how often expired leases are removed and
+ // quiet hosts are marked offline.
+ DefaultMaintenanceInterval = time.Minute
+ // DefaultOfflineThreshold is the heartbeat quiet period passed to
+ // MarkOfflineLeases. It is not the only cutoff: a host with no
+ // heartbeats, or one that is mid-install, is judged against its lease
+ // duration so a long image is not marked offline between DHCP renews.
+ DefaultOfflineThreshold = 10 * time.Minute
+)
+
+// leaseJanitor is the slice of LeaseService the background loop needs.
+type leaseJanitor interface {
+ CleanupExpiredLeases(ctx context.Context) error
+ MarkOfflineLeases(ctx context.Context, offlineThreshold time.Duration) error
+}
+
+// LeaseMaintainer periodically runs CleanupExpiredLeases and MarkOfflineLeases.
+// Stop cancels the maintainer's context and waits for the loop to return.
+type LeaseMaintainer struct {
+ leases leaseJanitor
+ interval time.Duration
+ offlineThreshold time.Duration
+
+ mu sync.Mutex
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+// NewLeaseMaintainer builds a maintainer. Non-positive durations fall back
+// to DefaultMaintenanceInterval and DefaultOfflineThreshold.
+func NewLeaseMaintainer(leases leaseJanitor, interval, offlineThreshold time.Duration) *LeaseMaintainer {
+ if interval <= 0 {
+ interval = DefaultMaintenanceInterval
+ }
+ if offlineThreshold <= 0 {
+ offlineThreshold = DefaultOfflineThreshold
+ }
+ return &LeaseMaintainer{
+ leases: leases,
+ interval: interval,
+ offlineThreshold: offlineThreshold,
+ }
+}
+
+// Start begins the background loop. The loop stops when parent is cancelled
+// or when Stop is called. A second Start while the loop is running does nothing.
+func (m *LeaseMaintainer) Start(parent context.Context) {
+ if m == nil || m.leases == nil {
+ return
+ }
+ if parent == nil {
+ parent = context.Background()
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.cancel != nil {
+ return
+ }
+ ctx, cancel := context.WithCancel(parent)
+ m.cancel = cancel
+ m.done = make(chan struct{})
+ go m.run(ctx, m.done)
+}
+
+// Stop cancels the loop and waits until it has returned.
+// Stop without a prior Start is a no-op.
+func (m *LeaseMaintainer) Stop() {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ cancel := m.cancel
+ done := m.done
+ m.cancel = nil
+ m.done = nil
+ m.mu.Unlock()
+
+ if cancel != nil {
+ cancel()
+ }
+ if done != nil {
+ <-done
+ }
+}
+
+func (m *LeaseMaintainer) run(ctx context.Context, done chan struct{}) {
+ defer close(done)
+
+ ticker := time.NewTicker(m.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ m.tick(ctx)
+ }
+ }
+}
+
+func (m *LeaseMaintainer) tick(ctx context.Context) {
+ if err := m.leases.CleanupExpiredLeases(ctx); err != nil && ctx.Err() == nil {
+ log.Printf("lease cleanup: %v", err)
+ }
+ if ctx.Err() != nil {
+ return
+ }
+ if err := m.leases.MarkOfflineLeases(ctx, m.offlineThreshold); err != nil && ctx.Err() == nil {
+ log.Printf("mark offline leases: %v", err)
+ }
+}
diff --git a/dhcp/maintenance_test.go b/dhcp/maintenance_test.go
new file mode 100644
index 0000000..b0a9543
--- /dev/null
+++ b/dhcp/maintenance_test.go
@@ -0,0 +1,112 @@
+package dhcp
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type fakeJanitor struct {
+ cleanupHits atomic.Int32
+ offlineHits atomic.Int32
+ block chan struct{}
+ release chan struct{}
+}
+
+func (f *fakeJanitor) CleanupExpiredLeases(ctx context.Context) error {
+ f.cleanupHits.Add(1)
+ if f.block != nil {
+ select {
+ case <-f.block:
+ default:
+ close(f.block)
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-f.release:
+ return nil
+ }
+ }
+ return nil
+}
+
+func (f *fakeJanitor) MarkOfflineLeases(ctx context.Context, offlineThreshold time.Duration) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ f.offlineHits.Add(1)
+ return nil
+}
+
+func TestLeaseMaintainerStopBeforeStart(t *testing.T) {
+ m := NewLeaseMaintainer(&fakeJanitor{}, time.Hour, time.Hour)
+ done := make(chan struct{})
+ go func() {
+ m.Stop()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("Stop before Start did not return")
+ }
+}
+
+func TestLeaseMaintainerStopCancelsBlockedTick(t *testing.T) {
+ fake := &fakeJanitor{
+ block: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ parent, cancelParent := context.WithCancel(context.Background())
+ defer cancelParent()
+
+ m := NewLeaseMaintainer(fake, 15*time.Millisecond, time.Minute)
+ m.Start(parent)
+
+ select {
+ case <-fake.block:
+ case <-time.After(time.Second):
+ t.Fatal("maintenance tick did not start")
+ }
+
+ stopped := make(chan struct{})
+ go func() {
+ m.Stop()
+ close(stopped)
+ }()
+
+ select {
+ case <-stopped:
+ case <-time.After(time.Second):
+ t.Fatal("Stop did not return while a tick was blocked on context")
+ }
+
+ // The cancelled tick must not mark hosts offline after shutdown.
+ time.Sleep(40 * time.Millisecond)
+ if fake.offlineHits.Load() != 0 {
+ t.Fatalf("offline marking ran after cancellation: %d", fake.offlineHits.Load())
+ }
+}
+
+func TestLeaseMaintainerStopsTickingAfterStop(t *testing.T) {
+ fake := &fakeJanitor{}
+ m := NewLeaseMaintainer(fake, 20*time.Millisecond, time.Minute)
+ m.Start(context.Background())
+
+ deadline := time.Now().Add(time.Second)
+ for fake.cleanupHits.Load() == 0 && time.Now().Before(deadline) {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if fake.cleanupHits.Load() == 0 {
+ t.Fatal("expected at least one cleanup tick")
+ }
+
+ m.Stop()
+ after := fake.cleanupHits.Load()
+ time.Sleep(60 * time.Millisecond)
+ if got := fake.cleanupHits.Load(); got != after {
+ t.Fatalf("cleanup ran after Stop: before %d after %d", after, got)
+ }
+}
diff --git a/dhcp/menu.go b/dhcp/menu.go
new file mode 100644
index 0000000..1951835
--- /dev/null
+++ b/dhcp/menu.go
@@ -0,0 +1,154 @@
+package dhcp
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+// MenuName is the friendly OS image or menu label for a lease.
+// An empty string means no menu is assigned. The value is never a filesystem path.
+func MenuName(menu BootMenu) string {
+ osName := strings.TrimSpace(menu.OS)
+ version := strings.TrimSpace(menu.Version)
+ switch {
+ case osName != "" && version != "" && !looksLikePath(osName) && !looksLikePath(version):
+ return osName + " " + version
+ case osName != "" && !looksLikePath(osName):
+ return osName
+ case version != "" && !looksLikePath(version):
+ return version
+ }
+ label := strings.TrimSpace(menu.TemplateName)
+ if label != "" && !looksLikePath(label) {
+ return label
+ }
+ return ""
+}
+
+// MenuNameForLease returns the stored friendly name, or reads the current
+// TFTP pxelinux.cfg file for mac when the lease has no menu fields.
+// filename is consulted only when it stays inside tftpRoot. A path from an
+// older temporary root is ignored, so a restart cannot surface it.
+func MenuNameForLease(menu BootMenu, tftpRoot, mac, filename string) string {
+ if name := MenuName(menu); name != "" {
+ return name
+ }
+ if name := menuNameFromFile(filepath.Join(tftpRoot, "pxelinux.cfg", pxeMACFile(mac))); name != "" {
+ return name
+ }
+ if filename != "" && pathInside(tftpRoot, filename) {
+ return menuNameFromFile(filename)
+ }
+ return ""
+}
+
+// MenuNameFromPXE reads a PXE config body and returns a display name.
+// A kernel path such as ubuntu/24.04/vmlinuz becomes "ubuntu 24.04".
+// MENU LABEL and LABEL are the fallback. Paths are not returned.
+func MenuNameFromPXE(content string) string {
+ var kernel, menuLabel, label string
+ for _, line := range strings.Split(content, "\n") {
+ fields := strings.Fields(strings.TrimSpace(line))
+ if len(fields) < 2 {
+ continue
+ }
+ switch strings.ToUpper(fields[0]) {
+ case "KERNEL":
+ kernel = fields[1]
+ case "LABEL":
+ if label == "" {
+ label = fields[1]
+ }
+ case "MENU":
+ if strings.EqualFold(fields[1], "LABEL") && len(fields) > 2 && menuLabel == "" {
+ menuLabel = strings.Join(fields[2:], " ")
+ }
+ }
+ }
+ if name := nameFromKernelPath(kernel); name != "" {
+ return name
+ }
+ menuLabel = strings.TrimSpace(strings.TrimPrefix(menuLabel, "Install "))
+ menuLabel = strings.TrimSpace(strings.TrimPrefix(menuLabel, "install "))
+ if menuLabel != "" && !looksLikePath(menuLabel) {
+ return menuLabel
+ }
+ if label != "" && !looksLikePath(label) {
+ return label
+ }
+ return ""
+}
+
+func menuNameFromFile(path string) string {
+ if path == "" {
+ return ""
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return MenuNameFromPXE(string(body))
+}
+
+func nameFromKernelPath(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ parts := strings.Split(filepath.ToSlash(path), "/")
+ if len(parts) < 3 {
+ return ""
+ }
+ version := parts[len(parts)-2]
+ name := parts[len(parts)-3]
+ if name == "" || looksLikePath(name) || !containsDigit(version) {
+ return ""
+ }
+ return name + " " + version
+}
+
+func pxeMACFile(mac string) string {
+ mac = strings.ToLower(strings.TrimSpace(mac))
+ mac = strings.ReplaceAll(mac, ":", "-")
+ return "01-" + mac
+}
+
+func looksLikePath(value string) bool {
+ if value == "" {
+ return false
+ }
+ if strings.ContainsAny(value, `/\`) || strings.Contains(value, "..") {
+ return true
+ }
+ return filepath.IsAbs(value)
+}
+
+func containsDigit(value string) bool {
+ for _, r := range value {
+ if unicode.IsDigit(r) {
+ return true
+ }
+ }
+ return false
+}
+
+func pathInside(root, path string) bool {
+ if root == "" || path == "" {
+ return false
+ }
+ rootAbs, err := filepath.Abs(root)
+ if err != nil {
+ return false
+ }
+ pathAbs, err := filepath.Abs(path)
+ if err != nil {
+ return false
+ }
+ rel, err := filepath.Rel(rootAbs, pathAbs)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
diff --git a/dhcp/menu_test.go b/dhcp/menu_test.go
new file mode 100644
index 0000000..1faa427
--- /dev/null
+++ b/dhcp/menu_test.go
@@ -0,0 +1,182 @@
+package dhcp
+
+import (
+ "context"
+ "net"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "ignite/db"
+
+ "github.com/krolaw/dhcp4"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMenuNameUsesOSAndVersion(t *testing.T) {
+ require.Equal(t, "ubuntu 24.04", MenuName(BootMenu{OS: "ubuntu", Version: "24.04"}))
+ require.Equal(t, "ubuntu", MenuName(BootMenu{OS: "ubuntu"}))
+ require.Equal(t, "", MenuName(BootMenu{}))
+ require.Equal(t, "", MenuName(BootMenu{
+ Filename: "/tmp/ignite-tftp.UPoTst/pxelinux.cfg/01-00-11-22-33-44-55",
+ }))
+ require.Equal(t, "", MenuName(BootMenu{OS: "/tmp/ignite-tftp.UPoTst/menu"}))
+ require.Equal(t, "custom", MenuName(BootMenu{TemplateName: "custom"}))
+}
+
+func TestMenuNameFromPXE(t *testing.T) {
+ body := "default ubuntu\nLABEL ubuntu\n MENU LABEL Install ubuntu\n KERNEL ubuntu/24.04/vmlinuz\n"
+ require.Equal(t, "ubuntu 24.04", MenuNameFromPXE(body))
+ require.Equal(t, "debian", MenuNameFromPXE("LABEL debian\n"))
+ require.Equal(t, "", MenuNameFromPXE("KERNEL /tmp/ignite-tftp.UPoTst/vmlinuz\n"))
+}
+
+func TestMenuNameForLeaseReadsCurrentRootOnly(t *testing.T) {
+ root := t.TempDir()
+ mac := "00:11:22:33:44:66"
+ dir := filepath.Join(root, "pxelinux.cfg")
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ body := "LABEL ubuntu\n KERNEL ubuntu/24.04/vmlinuz\n"
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "01-00-11-22-33-44-66"), []byte(body), 0o644))
+
+ stale := filepath.Join(t.TempDir(), "pxelinux.cfg", "01-00-11-22-33-44-66")
+ require.NoError(t, os.MkdirAll(filepath.Dir(stale), 0o755))
+ require.NoError(t, os.WriteFile(stale, []byte("LABEL ignored\n"), 0o644))
+
+ name := MenuNameForLease(BootMenu{Filename: stale}, root, mac, stale)
+ require.Equal(t, "ubuntu 24.04", name)
+
+ outside := MenuNameForLease(BootMenu{}, root, "aa:bb:cc:dd:ee:ff", stale)
+ require.Equal(t, "", outside)
+}
+
+func TestRequestForNewIPKeepsMenuThroughImaging(t *testing.T) {
+ mac, err := net.ParseMAC("00:11:22:33:44:55")
+ require.NoError(t, err)
+ ctx := context.Background()
+ repo := newMemLeases()
+ server := &Server{
+ ID: "srv",
+ IP: net.ParseIP("10.0.1.1"),
+ IPStart: net.ParseIP("10.0.1.100"),
+ LeaseRange: 20,
+ LeaseDuration: time.Hour,
+ }
+ old := &Lease{
+ ID: "old",
+ MAC: mac.String(),
+ IP: net.ParseIP("10.0.1.50"),
+ ServerID: server.ID,
+ Expiry: time.Now().Add(time.Hour),
+ State: StateAssigned,
+ Menu: BootMenu{
+ Filename: "/tmp/ignite-tftp.UPoTst/pxelinux.cfg/01-00-11-22-33-44-55",
+ OS: "ubuntu",
+ Version: "24.04",
+ },
+ ProvisionToken: "tok",
+ }
+ require.NoError(t, repo.Save(ctx, old))
+
+ handler := NewProtocolHandler(server, repo)
+ pkt := dhcp4.RequestPacket(dhcp4.Request, mac, old.IP, []byte{1, 2, 3, 4}, false, []dhcp4.Option{
+ {Code: dhcp4.OptionRequestedIPAddress, Value: net.ParseIP("10.0.1.100").To4()},
+ })
+ reply := handler.ServeDHCP(pkt, dhcp4.Request, pkt.ParseOptions())
+ require.NotNil(t, reply)
+
+ got, err := repo.GetByMAC(ctx, mac.String())
+ require.NoError(t, err)
+ require.Equal(t, "10.0.1.100", got.IP.String())
+ require.Equal(t, "ubuntu", got.Menu.OS)
+ require.Equal(t, "24.04", got.Menu.Version)
+ require.Equal(t, "tok", got.ProvisionToken)
+
+ svc := NewDHCPLeaseService(repo, &memServerRepo{servers: map[string]*Server{server.ID: server}})
+ require.NoError(t, svc.UpdateLeaseState(ctx, mac.String(), string(StateImaging), "kickstart"))
+ got, err = repo.GetByMAC(ctx, mac.String())
+ require.NoError(t, err)
+ require.Equal(t, StateImaging, got.State)
+ require.Equal(t, "ubuntu 24.04", MenuName(got.Menu))
+ require.NotContains(t, MenuName(got.Menu), "ignite-tftp")
+}
+
+type memLeases struct {
+ mu sync.Mutex
+ byID map[string]*Lease
+ byMAC map[string]string
+}
+
+func newMemLeases() *memLeases {
+ return &memLeases{byID: map[string]*Lease{}, byMAC: map[string]string{}}
+}
+
+func (m *memLeases) Save(_ context.Context, lease *Lease) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *lease
+ m.byID[lease.ID] = &cp
+ m.byMAC[lease.MAC] = lease.ID
+ return nil
+}
+
+func (m *memLeases) Get(_ context.Context, id string) (*Lease, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ lease, ok := m.byID[id]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ cp := *lease
+ return &cp, nil
+}
+
+func (m *memLeases) GetByMAC(_ context.Context, mac string) (*Lease, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ id, ok := m.byMAC[mac]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ cp := *m.byID[id]
+ return &cp, nil
+}
+
+func (m *memLeases) GetByServerID(_ context.Context, serverID string) ([]*Lease, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*Lease
+ for _, lease := range m.byID {
+ if lease.ServerID == serverID {
+ cp := *lease
+ out = append(out, &cp)
+ }
+ }
+ return out, nil
+}
+
+func (m *memLeases) Delete(_ context.Context, id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if lease, ok := m.byID[id]; ok {
+ delete(m.byMAC, lease.MAC)
+ }
+ delete(m.byID, id)
+ return nil
+}
+
+func (m *memLeases) DeleteByMAC(_ context.Context, mac string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if id, ok := m.byMAC[mac]; ok {
+ delete(m.byID, id)
+ }
+ delete(m.byMAC, mac)
+ return nil
+}
+
+func (m *memLeases) DeleteByServerID(context.Context, string) error { return nil }
+func (m *memLeases) GetExpired(context.Context) ([]*Lease, error) { return nil, nil }
+func (m *memLeases) CleanupExpired(context.Context) error { return nil }
diff --git a/dhcp/models.go b/dhcp/models.go
index 4fe69b6..9aee086 100644
--- a/dhcp/models.go
+++ b/dhcp/models.go
@@ -29,18 +29,27 @@ type DHCPOptions struct {
// Lease represents an IP lease assignment
type Lease struct {
- ID string `json:"id"`
- IP net.IP `json:"ip"`
- MAC string `json:"mac"`
- Expiry time.Time `json:"expiry"`
- Reserved bool `json:"reserved"`
- ServerID string `json:"server_id"`
- Menu BootMenu `json:"menu"`
- IPMI IPMI `json:"ipmi"`
- State LeaseState `json:"state"`
- StateUpdatedAt time.Time `json:"state_updated_at"`
- LastSeen time.Time `json:"last_seen"`
- StateHistory []StateTransition `json:"state_history"`
+ ID string `json:"id"`
+ IP net.IP `json:"ip"`
+ MAC string `json:"mac"`
+ Expiry time.Time `json:"expiry"`
+ Reserved bool `json:"reserved"`
+ ServerID string `json:"server_id"`
+ Menu BootMenu `json:"menu"`
+ IPMI IPMI `json:"ipmi"`
+ State LeaseState `json:"state"`
+ StateUpdatedAt time.Time `json:"state_updated_at"`
+ LastSeen time.Time `json:"last_seen"`
+ // LastHeartbeat is set only by an installer heartbeat, not by a DHCP
+ // renew. A zero time means this host has never heartbeated, so offline
+ // detection uses the full lease duration.
+ LastHeartbeat time.Time `json:"last_heartbeat,omitempty"`
+ StateHistory []StateTransition `json:"state_history"`
+ // ProvisionToken authenticates provisioning callbacks for this lease.
+ // It is a per-lease secret, not an admin session. Empty means the lease
+ // was stored before tokens existed; callbacks are rejected until a token
+ // is issued.
+ ProvisionToken string `json:"provision_token,omitempty"`
}
// StateTransition represents a state change event
@@ -122,6 +131,82 @@ func (l *Lease) IsActive() bool {
return l.State != StateOffline && l.State != StateFailed && l.State != StateComplete
}
+// NoteActivity records a DHCP renew (or any non-heartbeat presence) and
+// clears an offline mark. The restored stage is the last non-offline state,
+// not a forced assigned.
+func (l *Lease) NoteActivity(source string) {
+ if l == nil {
+ return
+ }
+ l.LastSeen = time.Now()
+ if l.State == StateOffline {
+ l.RestoreFromOffline(source)
+ }
+}
+
+// RestoreFromOffline returns the lease to the last stage that was not
+// offline. With no such history it falls back to assigned.
+func (l *Lease) RestoreFromOffline(source string) {
+ if l == nil {
+ return
+ }
+ l.UpdateState(l.lastNonOfflineState(), source)
+}
+
+func (l *Lease) lastNonOfflineState() LeaseState {
+ for i := len(l.StateHistory) - 1; i >= 0; i-- {
+ state := l.StateHistory[i].ToState
+ if state != "" && state != StateOffline {
+ return state
+ }
+ }
+ return StateAssigned
+}
+
+// inProgress reports a stage that is mid-install. Those leases use the full
+// lease duration as the offline window so a long image is not flagged
+// between heartbeats.
+func (l *Lease) inProgress() bool {
+ if l == nil {
+ return false
+ }
+ switch l.State {
+ case StatePXERequested, StateBooting, StateImaging, StateImaged, StateConfiguring:
+ return true
+ default:
+ return false
+ }
+}
+
+// OfflineWindow is how long the lease may go without LastSeen activity
+// before it is marked offline.
+//
+// A host that has never heartbeated, or that is mid-install, uses the full
+// lease duration: DHCP renews typically arrive at T1 (half the lease), and
+// a 10-minute heartbeat cutoff would mark a 2-hour install offline.
+// Once heartbeats exist and the stage is not in progress, the window is the
+// greater of heartbeatThreshold and half the lease, so a renewing host is
+// not flagged in the gap between T1 renews.
+func (l *Lease) OfflineWindow(leaseDuration, heartbeatThreshold time.Duration) time.Duration {
+ if heartbeatThreshold < 0 {
+ heartbeatThreshold = 0
+ }
+ if l == nil {
+ return heartbeatThreshold
+ }
+ if l.LastHeartbeat.IsZero() || l.inProgress() {
+ if leaseDuration > 0 {
+ return leaseDuration
+ }
+ return heartbeatThreshold
+ }
+ half := leaseDuration / 2
+ if half > heartbeatThreshold {
+ return half
+ }
+ return heartbeatThreshold
+}
+
// BootMenu contains PXE boot configuration
type BootMenu struct {
Filename string `json:"filename"`
@@ -151,6 +236,19 @@ func (l *Lease) IsExpired() bool {
return time.Now().After(l.Expiry)
}
+// OccupiesAddress reports whether the lease holds its IP against other
+// clients at the given time. A reservation keeps the address after Expiry;
+// cleanup does not delete reserved leases.
+func (l *Lease) OccupiesAddress(now time.Time) bool {
+ if l == nil || l.IP == nil {
+ return false
+ }
+ if l.Reserved {
+ return true
+ }
+ return !now.After(l.Expiry)
+}
+
// Extend extends the lease expiry time
func (l *Lease) Extend(duration time.Duration) {
l.Expiry = time.Now().Add(duration)
diff --git a/dhcp/protocol_handler.go b/dhcp/protocol_handler.go
index beee2a5..3e0fb46 100644
--- a/dhcp/protocol_handler.go
+++ b/dhcp/protocol_handler.go
@@ -18,6 +18,12 @@ import (
// (or has already been stopped).
var ErrHandlerNotRunning = errors.New("DHCP handler is not running")
+// listenDHCP binds the DHCP socket. Tests replace it so a start can succeed
+// or fail without owning UDP port 67.
+var listenDHCP = func(network, address string) (net.PacketConn, error) {
+ return net.ListenPacket(network, address)
+}
+
// ProtocolBootFiles carries the boot-file settings a ProtocolHandler needs.
// The values are loaded once at construction time (see WithBootFiles) so the
// packet path never pays for config.LoadDefault() per packet.
@@ -97,7 +103,7 @@ func (h *ProtocolHandler) Start() error {
// Bind first; only publish state after the bind succeeds.
ctx, cancel := context.WithCancel(context.Background())
- conn, err := net.ListenPacket("udp4", ":67")
+ conn, err := listenDHCP("udp4", ":67")
if err != nil {
cancel()
return fmt.Errorf("failed to listen on UDP port 67: %w", err)
@@ -173,10 +179,11 @@ func (h *ProtocolHandler) ServeDHCP(p d4.Packet, msgType d4.MessageType, options
}
// newLease builds a fully-initialized lease record for a fresh allocation:
-// unique ID, assigned state, state-history entry, and timestamps.
-func (h *ProtocolHandler) newLease(mac string, ip net.IP) *Lease {
+// unique ID, assigned state, state-history entry, timestamps, and a
+// per-lease provisioning token.
+func (h *ProtocolHandler) newLease(mac string, ip net.IP) (*Lease, error) {
now := time.Now()
- return &Lease{
+ lease := &Lease{
ID: uuid.New().String(),
MAC: mac,
IP: ip,
@@ -190,6 +197,23 @@ func (h *ProtocolHandler) newLease(mac string, ip net.IP) *Lease {
{FromState: "", ToState: StateAssigned, Timestamp: now, Source: "dhcp"},
},
}
+ if err := ensureProvisionToken(lease); err != nil {
+ return nil, err
+ }
+ return lease, nil
+}
+
+// carryBootConfig copies the boot menu, IPMI settings, and provision token
+// onto a lease that replaced an older row for the same MAC.
+func carryBootConfig(dst, src *Lease) {
+ if dst == nil || src == nil {
+ return
+ }
+ dst.Menu = src.Menu
+ dst.IPMI = src.IPMI
+ if src.ProvisionToken != "" {
+ dst.ProvisionToken = src.ProvisionToken
+ }
}
// handleDiscover processes DHCP Discover messages
@@ -201,6 +225,11 @@ func (h *ProtocolHandler) handleDiscover(p d4.Packet, options d4.Options) d4.Pac
ctx := context.Background()
mac := p.CHAddr().String()
+ unlock, err := lockLease(ctx, mac)
+ if err != nil {
+ return nil
+ }
+ defer unlock()
// Re-offer the current IP when the MAC already has a live lease — or a
// static reservation, which never expires for offer purposes — on this
@@ -230,6 +259,11 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
ctx := context.Background()
mac := p.CHAddr().String()
+ unlock, lerr := lockLease(ctx, mac)
+ if lerr != nil {
+ return nil
+ }
+ defer unlock()
requestedIP := net.IP(options[d4.OptionRequestedIPAddress])
if requestedIP == nil {
@@ -265,7 +299,13 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
// Existing lease - extend if requested IP matches or no specific IP requested
if requestedIP == nil || lease.IP.Equal(requestedIP) {
lease.Extend(h.server.LeaseDuration)
- lease.LastSeen = time.Now()
+ // A renew is activity. It clears offline and restores the stage
+ // the host was in, instead of leaving the offline mark stuck.
+ lease.NoteActivity("dhcp")
+ if err := ensureProvisionToken(lease); err != nil {
+ log.Printf("Failed to issue provision token for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
// Never ACK an unpersisted lease.
if err := h.leaseRepo.Save(ctx, lease); err != nil {
log.Printf("Failed to save extended lease for MAC %s: %v", mac, err)
@@ -283,7 +323,17 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
if derr := h.leaseRepo.DeleteByMAC(ctx, mac); derr != nil {
log.Printf("Failed to delete stale lease for MAC %s: %v", mac, derr)
}
- lease = h.newLease(mac, requestedIP.To4())
+ previous := lease
+ lease, err = h.newLease(mac, requestedIP.To4())
+ if err != nil {
+ log.Printf("Failed to build lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
+ // The installer often requests its own address, which is not the
+ // address DHCP handed out. Replacing the row must keep the boot
+ // menu: otherwise the later imaging callback lands on a lease whose
+ // menu is empty.
+ carryBootConfig(lease, previous)
// Never ACK an unpersisted lease.
if err := h.leaseRepo.Save(ctx, lease); err != nil {
log.Printf("Failed to save lease for MAC %s: %v", mac, err)
@@ -294,7 +344,11 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
// New lease request - use the requested IP when it is valid.
if validRequest {
- lease := h.newLease(mac, requestedIP.To4())
+ lease, err := h.newLease(mac, requestedIP.To4())
+ if err != nil {
+ log.Printf("Failed to build lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
// Never ACK an unpersisted lease.
if err := h.leaseRepo.Save(ctx, lease); err != nil {
log.Printf("Failed to save lease for MAC %s: %v", mac, err)
@@ -311,7 +365,11 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
}
// Create new lease
- lease = h.newLease(mac, ip)
+ lease, err = h.newLease(mac, ip)
+ if err != nil {
+ log.Printf("Failed to build lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
// Never ACK an unpersisted lease.
if err := h.leaseRepo.Save(ctx, lease); err != nil {
@@ -322,10 +380,19 @@ func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Pack
return h.createAckPacket(p, ip)
}
-// handleRelease processes DHCP Release/Decline messages
+// handleRelease processes DHCP Release/Decline messages.
+// The delete shares the allocation lock so it cannot race a renewal.
func (h *ProtocolHandler) handleRelease(p d4.Packet, options d4.Options) {
+ h.allocMu.Lock()
+ defer h.allocMu.Unlock()
+
ctx := context.Background()
mac := p.CHAddr().String()
+ unlock, lerr := lockLease(ctx, mac)
+ if lerr != nil {
+ return
+ }
+ defer unlock()
if err := h.leaseRepo.DeleteByMAC(ctx, mac); err != nil {
log.Printf("Failed to release lease for MAC %s: %v", mac, err)
@@ -429,8 +496,9 @@ func (h *ProtocolHandler) isIPAvailable(ctx context.Context, ip net.IP, excludeM
return false
}
+ now := time.Now()
for _, lease := range leases {
- if lease.IP.Equal(ip) && lease.MAC != excludeMAC && !lease.IsExpired() {
+ if lease.IP.Equal(ip) && lease.MAC != excludeMAC && lease.OccupiesAddress(now) {
return false
}
}
diff --git a/dhcp/repository.go b/dhcp/repository.go
index 27a2395..92cde47 100644
--- a/dhcp/repository.go
+++ b/dhcp/repository.go
@@ -3,6 +3,7 @@ package dhcp
import (
"context"
+ "encoding/json"
"fmt"
"net"
"time"
@@ -91,16 +92,44 @@ func (r *BoltLeaseRepository) macIndexBucket() string {
return r.bucket + "_by_mac"
}
-// Save saves a lease to the repository and maintains the MAC index
+// Save saves a lease and its MAC index in one transaction. A failure rolls
+// both writes back, so the index cannot point at a lease row that was not
+// committed (and a MAC change cannot leave the old index entry behind).
func (r *BoltLeaseRepository) Save(ctx context.Context, lease *Lease) error {
- // Drop any stale index entry if the lease's MAC changed since it was stored.
- if existing, err := r.Get(ctx, lease.ID); err == nil && existing != nil && existing.MAC != lease.MAC {
- _ = r.db.DeleteKV(ctx, r.macIndexBucket(), []byte(existing.MAC))
+ if lease == nil || lease.ID == "" {
+ return fmt.Errorf("lease id is required")
}
- if err := r.repo.Save(ctx, lease.ID, lease); err != nil {
+ if lease.MAC == "" {
+ return fmt.Errorf("lease MAC is required")
+ }
+ data, err := json.Marshal(lease)
+ if err != nil {
+ return fmt.Errorf("failed to marshal lease: %w", err)
+ }
+ return r.db.Update(ctx, func(tx db.Tx) error {
+ return writeLease(tx, r.bucket, r.macIndexBucket(), lease, data)
+ })
+}
+
+// writeLease writes the lease row and the MAC index. When the stored MAC
+// changed, the previous index entry is removed in the same transaction.
+func writeLease(tx db.Tx, bucket, indexBucket string, lease *Lease, data []byte) error {
+ existing, err := tx.Get(bucket, []byte(lease.ID))
+ if err != nil {
return err
}
- if err := r.db.PutKV(ctx, r.macIndexBucket(), []byte(lease.MAC), []byte(lease.ID)); err != nil {
+ if len(existing) > 0 {
+ var prev Lease
+ if err := json.Unmarshal(existing, &prev); err == nil && prev.MAC != "" && prev.MAC != lease.MAC {
+ if err := tx.Delete(indexBucket, []byte(prev.MAC)); err != nil {
+ return err
+ }
+ }
+ }
+ if err := tx.Put(bucket, []byte(lease.ID), data); err != nil {
+ return err
+ }
+ if err := tx.Put(indexBucket, []byte(lease.MAC), []byte(lease.ID)); err != nil {
return fmt.Errorf("failed to update MAC index: %w", err)
}
return nil
@@ -112,30 +141,119 @@ func (r *BoltLeaseRepository) Get(ctx context.Context, id string) (*Lease, error
}
// GetByMAC retrieves a lease by MAC address, using the secondary index.
-// Falls back to a full scan (which repairs the index) when the index
-// misses — e.g. for leases written before the index existed.
+// A canonical miss scans inside one write transaction and, when the stored
+// spelling differs, rewrites the row and the index before returning. The
+// repair is not a separate PutKV: it commits with the scan or not at all.
+// Callers that already hold lockLease must not take it again here; the
+// bbolt write transaction is the lock for the index update.
func (r *BoltLeaseRepository) GetByMAC(ctx context.Context, mac string) (*Lease, error) {
- if id, err := r.db.GetKV(ctx, r.macIndexBucket(), []byte(mac)); err == nil && len(id) > 0 {
- if lease, gerr := r.Get(ctx, string(id)); gerr == nil && lease != nil && lease.MAC == mac {
+ canon, canonErr := CanonicalMAC(mac)
+ keys := []string{mac}
+ if canonErr == nil {
+ keys = []string{canon}
+ if canon != mac {
+ keys = append(keys, mac)
+ }
+ }
+ for _, key := range keys {
+ if lease, ok := r.leaseByIndex(ctx, key, canon); ok {
return lease, nil
}
- // Stale index entry: fall through to the full scan below.
}
- leases, err := r.GetAll(ctx)
+ var found *Lease
+ err := r.db.Update(ctx, func(tx db.Tx) error {
+ for _, key := range keys {
+ id, gerr := tx.Get(r.macIndexBucket(), []byte(key))
+ if gerr != nil || len(id) == 0 {
+ continue
+ }
+ raw, gerr := tx.Get(r.bucket, id)
+ if gerr != nil || len(raw) == 0 {
+ continue
+ }
+ var lease Lease
+ if json.Unmarshal(raw, &lease) != nil {
+ continue
+ }
+ if macMatches(&lease, mac, canon) && (canonErr != nil || lease.MAC == canon) {
+ cp := lease
+ found = &cp
+ return nil
+ }
+ }
+
+ var match *Lease
+ err := tx.ForEach(r.bucket, func(k, v []byte) error {
+ var lease Lease
+ if json.Unmarshal(v, &lease) != nil {
+ return nil
+ }
+ if !macMatches(&lease, mac, canon) {
+ return nil
+ }
+ cp := lease
+ match = &cp
+ return db.ErrStopIteration
+ })
+ if err != nil {
+ return err
+ }
+ if match == nil {
+ return fmt.Errorf("%w: lease for MAC %s", db.ErrNotFound, mac)
+ }
+ // Rewrite after ForEach. bbolt forbids mutating the bucket being walked.
+ if canonErr == nil && match.MAC != canon {
+ match.MAC = canon
+ }
+ data, err := json.Marshal(match)
+ if err != nil {
+ return fmt.Errorf("failed to marshal lease: %w", err)
+ }
+ if err := writeLease(tx, r.bucket, r.macIndexBucket(), match, data); err != nil {
+ return err
+ }
+ found = match
+ return nil
+ })
if err != nil {
return nil, err
}
+ if found == nil {
+ return nil, fmt.Errorf("%w: lease for MAC %s", db.ErrNotFound, mac)
+ }
+ return found, nil
+}
- for _, lease := range leases {
- if lease.MAC == mac {
- // Repair the index for next time (best effort).
- _ = r.db.PutKV(ctx, r.macIndexBucket(), []byte(mac), []byte(lease.ID))
- return lease, nil
- }
+// leaseByIndex returns the lease when the index key points at a row whose
+// MAC is already the canonical spelling. A legacy spelling falls through so
+// the write transaction can repair it.
+func (r *BoltLeaseRepository) leaseByIndex(ctx context.Context, key, canon string) (*Lease, bool) {
+ id, err := r.db.GetKV(ctx, r.macIndexBucket(), []byte(key))
+ if err != nil || len(id) == 0 {
+ return nil, false
+ }
+ lease, err := r.Get(ctx, string(id))
+ if err != nil || lease == nil || !macMatches(lease, key, canon) {
+ return nil, false
+ }
+ if canon != "" && lease.MAC != canon {
+ return nil, false
}
+ return lease, true
+}
- return nil, fmt.Errorf("%w: lease for MAC %s", db.ErrNotFound, mac)
+// macMatches reports whether lease.MAC is the requested spelling or the
+// same 48-bit address under another conventional spelling.
+func macMatches(lease *Lease, raw, canon string) bool {
+ if lease == nil || lease.MAC == "" {
+ return false
+ }
+ if lease.MAC == raw || (canon != "" && lease.MAC == canon) {
+ return true
+ }
+ got, err := CanonicalMAC(lease.MAC)
+ return err == nil && canon != "" && got == canon
}
// GetByServerID retrieves all leases for a specific server
@@ -170,12 +288,23 @@ func (r *BoltLeaseRepository) GetAll(ctx context.Context) ([]*Lease, error) {
return leases, nil
}
-// Delete removes a lease by ID and cleans up its MAC index entry
+// Delete removes a lease and its MAC index entry in one transaction.
func (r *BoltLeaseRepository) Delete(ctx context.Context, id string) error {
- if existing, err := r.Get(ctx, id); err == nil && existing != nil {
- _ = r.db.DeleteKV(ctx, r.macIndexBucket(), []byte(existing.MAC))
- }
- return r.repo.Delete(ctx, id)
+ return r.db.Update(ctx, func(tx db.Tx) error {
+ existing, err := tx.Get(r.bucket, []byte(id))
+ if err != nil {
+ return err
+ }
+ if len(existing) > 0 {
+ var prev Lease
+ if json.Unmarshal(existing, &prev) == nil && prev.MAC != "" {
+ if err := tx.Delete(r.macIndexBucket(), []byte(prev.MAC)); err != nil {
+ return err
+ }
+ }
+ }
+ return tx.Delete(r.bucket, []byte(id))
+ })
}
// DeleteByMAC removes a lease by MAC address
@@ -231,6 +360,9 @@ func (r *BoltLeaseRepository) CleanupExpired(ctx context.Context) error {
}
for _, lease := range expiredLeases {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
if err := r.Delete(ctx, lease.ID); err != nil {
return fmt.Errorf("failed to delete expired lease %s: %w", lease.ID, err)
}
diff --git a/dhcp/repository_test.go b/dhcp/repository_test.go
index a90e9a2..dfe6541 100644
--- a/dhcp/repository_test.go
+++ b/dhcp/repository_test.go
@@ -84,3 +84,31 @@ func TestBoltLeaseRepository_MACIndex(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, l2.ID, string(id))
}
+
+// TestBoltLeaseRepository_SaveIsAtomicAcrossMACChange checks that a MAC
+// change updates the row and both index keys together: the new key is
+// present and the old key is gone.
+func TestBoltLeaseRepository_SaveIsAtomicAcrossMACChange(t *testing.T) {
+ ctx := context.Background()
+ repo, database := newTestLeaseRepo(t)
+
+ lease := testLease("id-1", "aa:bb:cc:dd:ee:01", "192.168.1.101")
+ require.NoError(t, repo.Save(ctx, lease))
+
+ lease.MAC = "aa:bb:cc:dd:ee:09"
+ require.NoError(t, repo.Save(ctx, lease))
+
+ got, err := repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:09")
+ require.NoError(t, err)
+ assert.Equal(t, lease.ID, got.ID)
+
+ _, err = repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:01")
+ assert.ErrorIs(t, err, db.ErrNotFound)
+
+ oldID, err := database.GetKV(ctx, "leases_by_mac", []byte("aa:bb:cc:dd:ee:01"))
+ require.NoError(t, err)
+ assert.Empty(t, oldID)
+ newID, err := database.GetKV(ctx, "leases_by_mac", []byte("aa:bb:cc:dd:ee:09"))
+ require.NoError(t, err)
+ assert.Equal(t, lease.ID, string(newID))
+}
diff --git a/dhcp/review_fixes_test.go b/dhcp/review_fixes_test.go
new file mode 100644
index 0000000..2d9e80b
--- /dev/null
+++ b/dhcp/review_fixes_test.go
@@ -0,0 +1,373 @@
+package dhcp
+
+import (
+ "context"
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ "ignite/db"
+
+ "github.com/stretchr/testify/require"
+)
+
+func reviewRepo(t *testing.T) (*BoltLeaseRepository, db.Database) {
+ t.Helper()
+ repo, database := newTestLeaseRepo(t)
+ // The test database creates test_leases, not the repository bucket.
+ require.NoError(t, database.GetOrCreateBucket(context.Background(), "leases"))
+ return repo, database
+}
+
+func reviewServer(duration time.Duration) *Server {
+ return &Server{
+ ID: "srv",
+ IP: net.ParseIP("10.1.0.1").To4(),
+ IPStart: net.ParseIP("10.1.0.10").To4(),
+ LeaseRange: 20,
+ LeaseDuration: duration,
+ }
+}
+
+func TestGetByMACRepairsLegacySpelling(t *testing.T) {
+ ctx := context.Background()
+ repo, database := reviewRepo(t)
+
+ upper := testLease("id-upper", "AA:BB:CC:DD:EE:10", "10.1.0.10")
+ hyphen := testLease("id-hyphen", "AA-BB-CC-DD-EE-11", "10.1.0.11")
+ require.NoError(t, repo.Save(ctx, upper))
+ require.NoError(t, repo.Save(ctx, hyphen))
+
+ got, err := repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:10")
+ require.NoError(t, err)
+ require.Equal(t, "aa:bb:cc:dd:ee:10", got.MAC)
+ require.Equal(t, upper.ID, got.ID)
+
+ old, err := database.GetKV(ctx, "leases_by_mac", []byte("AA:BB:CC:DD:EE:10"))
+ require.NoError(t, err)
+ require.Empty(t, old)
+ indexed, err := database.GetKV(ctx, "leases_by_mac", []byte("aa:bb:cc:dd:ee:10"))
+ require.NoError(t, err)
+ require.Equal(t, upper.ID, string(indexed))
+
+ got, err = repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:11")
+ require.NoError(t, err)
+ require.Equal(t, "aa:bb:cc:dd:ee:11", got.MAC)
+ require.Equal(t, hyphen.ID, got.ID)
+}
+
+func TestAssignLeaseFindsLegacyMAC(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ legacy := testLease("id-legacy", "AA:BB:CC:DD:EE:10", "10.1.0.10")
+ legacy.ServerID = server.ID
+ legacy.Expiry = time.Now().Add(time.Hour)
+ require.NoError(t, repo.Save(ctx, legacy))
+
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+ got, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:10", net.ParseIP("10.1.0.10"))
+ require.NoError(t, err)
+ require.Equal(t, legacy.ID, got.ID)
+ require.True(t, got.IP.Equal(net.ParseIP("10.1.0.10")))
+
+ all, err := repo.GetAll(ctx)
+ require.NoError(t, err)
+ require.Len(t, all, 1)
+}
+
+func TestReleaseLeaseCanonicalizesMAC(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ legacy := testLease("id-rel", "AA:BB:CC:DD:EE:12", "10.1.0.12")
+ legacy.ServerID = server.ID
+ require.NoError(t, repo.Save(ctx, legacy))
+
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+ require.NoError(t, svc.ReleaseLease(ctx, "aa:bb:cc:dd:ee:12"))
+ _, err := repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:12")
+ require.ErrorIs(t, err, db.ErrNotFound)
+}
+
+func TestUpdateLeaseDoesNotRecreateDeletedRow(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ lease, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:13", net.ParseIP("10.1.0.13"))
+ require.NoError(t, err)
+ require.NoError(t, repo.Delete(ctx, lease.ID))
+
+ err = svc.UpdateLease(ctx, lease)
+ require.ErrorIs(t, err, db.ErrNotFound)
+ _, err = repo.Get(ctx, lease.ID)
+ require.ErrorIs(t, err, db.ErrNotFound)
+}
+
+func TestMarkOfflineSkipsMidInstallAndQuietRenew(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ imaging, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:14", net.ParseIP("10.1.0.14"))
+ require.NoError(t, err)
+ imaging, err = repo.Get(ctx, imaging.ID)
+ require.NoError(t, err)
+ imaging.State = StateImaging
+ imaging.LastSeen = time.Now().Add(-30 * time.Minute)
+ imaging.LastHeartbeat = imaging.LastSeen
+ require.NoError(t, repo.Save(ctx, imaging))
+
+ renewed, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:15", net.ParseIP("10.1.0.15"))
+ require.NoError(t, err)
+ renewed, err = repo.Get(ctx, renewed.ID)
+ require.NoError(t, err)
+ renewed.LastHeartbeat = time.Now().Add(-3 * time.Hour)
+ renewed.LastSeen = time.Now().Add(-20 * time.Minute)
+ require.NoError(t, repo.Save(ctx, renewed))
+
+ quiet, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:16", net.ParseIP("10.1.0.16"))
+ require.NoError(t, err)
+ quiet, err = repo.Get(ctx, quiet.ID)
+ require.NoError(t, err)
+ quiet.LastHeartbeat = time.Time{}
+ quiet.LastSeen = time.Now().Add(-30 * time.Minute)
+ require.NoError(t, repo.Save(ctx, quiet))
+
+ require.NoError(t, svc.MarkOfflineLeases(ctx, 10*time.Minute))
+
+ got, err := repo.Get(ctx, imaging.ID)
+ require.NoError(t, err)
+ require.Equal(t, StateImaging, got.State)
+ got, err = repo.Get(ctx, renewed.ID)
+ require.NoError(t, err)
+ require.Equal(t, StateAssigned, got.State)
+ got, err = repo.Get(ctx, quiet.ID)
+ require.NoError(t, err)
+ require.Equal(t, StateAssigned, got.State)
+}
+
+func TestMarkOfflineAfterHeartbeatGap(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ lease, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:17", net.ParseIP("10.1.0.17"))
+ require.NoError(t, err)
+ lease, err = repo.Get(ctx, lease.ID)
+ require.NoError(t, err)
+ lease.LastHeartbeat = time.Now().Add(-3 * time.Hour)
+ lease.LastSeen = time.Now().Add(-90 * time.Minute)
+ require.NoError(t, repo.Save(ctx, lease))
+
+ require.NoError(t, svc.MarkOfflineLeases(ctx, 10*time.Minute))
+ got, err := repo.Get(ctx, lease.ID)
+ require.NoError(t, err)
+ require.Equal(t, StateOffline, got.State)
+}
+
+func TestHeartbeatRestoresLastStage(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+ mac := "aa:bb:cc:dd:ee:18"
+
+ _, err := svc.AssignLease(ctx, server.ID, mac, net.ParseIP("10.1.0.18"))
+ require.NoError(t, err)
+ require.NoError(t, svc.UpdateLeaseState(ctx, mac, string(StateImaging), "kickstart"))
+ require.NoError(t, svc.UpdateLeaseState(ctx, mac, string(StateOffline), "timeout"))
+
+ require.NoError(t, svc.RecordHeartbeat(ctx, mac))
+ got, err := repo.GetByMAC(ctx, mac)
+ require.NoError(t, err)
+ require.Equal(t, StateImaging, got.State)
+ require.False(t, got.LastHeartbeat.IsZero())
+}
+
+func TestRenewClearsOffline(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+ mac := "aa:bb:cc:dd:ee:19"
+
+ _, err := svc.AssignLease(ctx, server.ID, mac, net.ParseIP("10.1.0.19"))
+ require.NoError(t, err)
+ require.NoError(t, svc.UpdateLeaseState(ctx, mac, string(StateImaging), "kickstart"))
+ require.NoError(t, svc.UpdateLeaseState(ctx, mac, string(StateOffline), "timeout"))
+
+ got, err := svc.AssignLease(ctx, server.ID, mac, net.ParseIP("10.1.0.19"))
+ require.NoError(t, err)
+ require.Equal(t, StateImaging, got.State)
+ require.False(t, got.LastSeen.IsZero())
+}
+
+// renewOnReadRepo makes the re-read inside MarkOfflineLeases look like a
+// DHCP renew landed after the scan and before the write.
+type renewOnReadRepo struct {
+ LeaseRepository
+ renewMAC string
+ once sync.Once
+}
+
+func (r *renewOnReadRepo) GetByMAC(ctx context.Context, mac string) (*Lease, error) {
+ lease, err := r.LeaseRepository.GetByMAC(ctx, mac)
+ if err != nil || lease == nil || lease.MAC != r.renewMAC {
+ return lease, err
+ }
+ r.once.Do(func() {
+ lease.LastSeen = time.Now()
+ _ = r.LeaseRepository.Save(ctx, lease)
+ })
+ return lease, nil
+}
+
+func TestMarkOfflineRechecksUnderLock(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+ mac := "aa:bb:cc:dd:ee:20"
+
+ lease, err := svc.AssignLease(ctx, server.ID, mac, net.ParseIP("10.1.0.20"))
+ require.NoError(t, err)
+ lease, err = repo.Get(ctx, lease.ID)
+ require.NoError(t, err)
+ lease.LastHeartbeat = time.Now().Add(-3 * time.Hour)
+ lease.LastSeen = time.Now().Add(-90 * time.Minute)
+ require.NoError(t, repo.Save(ctx, lease))
+
+ wrapped := NewDHCPLeaseService(&renewOnReadRepo{LeaseRepository: repo, renewMAC: mac}, memServers{server: server})
+ require.NoError(t, wrapped.MarkOfflineLeases(ctx, 10*time.Minute))
+ got, err := repo.Get(ctx, lease.ID)
+ require.NoError(t, err)
+ require.Equal(t, StateAssigned, got.State)
+}
+
+func TestLockLeaseHonorsContext(t *testing.T) {
+ mac := "aa:bb:cc:dd:ee:21"
+ unlock, err := lockLease(context.Background(), mac)
+ require.NoError(t, err)
+ released := false
+ defer func() {
+ if !released {
+ unlock()
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ _, err = lockLease(ctx, mac)
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+
+ unlock()
+ released = true
+ unlock, err = lockLease(context.Background(), mac)
+ require.NoError(t, err)
+ unlock()
+}
+
+func TestAssignOtherMACWhileLeaseLocked(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ svc := NewDHCPLeaseService(repo, memServers{server: server})
+
+ held := "aa:bb:cc:dd:ee:30"
+ unlock, err := lockLease(ctx, held)
+ require.NoError(t, err)
+ defer unlock()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := svc.AssignLease(ctx, server.ID, "aa:bb:cc:dd:ee:31", net.ParseIP("10.1.0.31"))
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("assign of a different MAC blocked on another lease lock")
+ }
+}
+
+func TestMarkOfflineStopsWhenContextCancelled(t *testing.T) {
+ repo, _ := reviewRepo(t)
+ svc := NewDHCPLeaseService(repo, memServers{server: reviewServer(time.Hour)})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err := svc.MarkOfflineLeases(ctx, time.Minute)
+ require.ErrorIs(t, err, context.Canceled)
+}
+
+func TestCleanupStopsWhenContextCancelled(t *testing.T) {
+ repo, _ := reviewRepo(t)
+ svc := NewDHCPLeaseService(repo, memServers{server: reviewServer(time.Hour)})
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err := svc.CleanupExpiredLeases(ctx)
+ require.ErrorIs(t, err, context.Canceled)
+}
+
+func TestCleanupExpiredHonorsContextBeforeDelete(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ lease := testLease("id-expired", "aa:bb:cc:dd:ee:22", "10.1.0.22")
+ lease.Expiry = time.Now().Add(-time.Minute)
+ require.NoError(t, repo.Save(ctx, lease))
+
+ cancelled, cancel := context.WithCancel(ctx)
+ cancel()
+ err := repo.CleanupExpired(cancelled)
+ require.ErrorIs(t, err, context.Canceled)
+ _, err = repo.Get(ctx, lease.ID)
+ require.NoError(t, err)
+}
+
+func TestDeleteServerTakesLeaseLock(t *testing.T) {
+ ctx := context.Background()
+ repo, _ := reviewRepo(t)
+ server := reviewServer(2 * time.Hour)
+ leases := NewDHCPLeaseService(repo, memServers{server: server})
+ servers := NewDHCPServerService(memServers{server: server}, repo)
+ mac := "aa:bb:cc:dd:ee:40"
+
+ lease, err := leases.AssignLease(ctx, server.ID, mac, net.ParseIP("10.1.0.40"))
+ require.NoError(t, err)
+
+ unlock, err := lockLease(ctx, mac)
+ require.NoError(t, err)
+ released := false
+ defer func() {
+ if !released {
+ unlock()
+ }
+ }()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- servers.DeleteServer(ctx, server.ID)
+ }()
+ select {
+ case err := <-done:
+ t.Fatalf("DeleteServer finished while the lease lock was held: %v", err)
+ case <-time.After(150 * time.Millisecond):
+ }
+
+ unlock()
+ released = true
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("DeleteServer did not finish after the lease lock was released")
+ }
+ _, err = repo.Get(ctx, lease.ID)
+ require.ErrorIs(t, err, db.ErrNotFound)
+}
diff --git a/dhcp/server_state.go b/dhcp/server_state.go
new file mode 100644
index 0000000..d42c373
--- /dev/null
+++ b/dhcp/server_state.go
@@ -0,0 +1,88 @@
+package dhcp
+
+import "sync"
+
+// ServerState is the listener state of one DHCP server.
+// It is the only status value the HTTP APIs return.
+//
+// running the listener is bound and serving
+// stopped it was started and then stopped, or the last start failed
+// configured it is defined and has not been started since this process booted
+type ServerState string
+
+const (
+ ServerStateRunning ServerState = "running"
+ ServerStateStopped ServerState = "stopped"
+ ServerStateConfigured ServerState = "configured"
+)
+
+func (s ServerState) valid() bool {
+ switch s {
+ case ServerStateRunning, ServerStateStopped, ServerStateConfigured:
+ return true
+ default:
+ return false
+ }
+}
+
+// serverStates is the process-wide record of what each DHCP listener is
+// doing. The persisted Server.Started flag is not a substitute: it survives
+// a restart in which the socket was never bound, and the HTTP handlers used
+// to keep a second copy that the service did not update.
+var serverStates = struct {
+ sync.RWMutex
+ byID map[string]ServerState
+ enabled bool
+}{byID: make(map[string]ServerState), enabled: true}
+
+// SetListenersEnabled records whether this process may run DHCP listeners.
+// When disabled, CurrentServerState reports configured for every server.
+// A running or stopped mark cannot make overall health partial or down
+// while DHCP is off.
+func SetListenersEnabled(enabled bool) {
+ serverStates.Lock()
+ serverStates.enabled = enabled
+ serverStates.Unlock()
+}
+
+// ListenersEnabled reports the last value from SetListenersEnabled.
+// The default is true.
+func ListenersEnabled() bool {
+ serverStates.RLock()
+ defer serverStates.RUnlock()
+ return serverStates.enabled
+}
+
+// NoteServerState records the listener state for serverID.
+// An empty id or a value other than the three ServerState constants is ignored.
+func NoteServerState(serverID string, state ServerState) {
+ if serverID == "" || !state.valid() {
+ return
+ }
+ serverStates.Lock()
+ serverStates.byID[serverID] = state
+ serverStates.Unlock()
+}
+
+// ClearServerState forgets a deleted server so it is not reported again.
+func ClearServerState(serverID string) {
+ if serverID == "" {
+ return
+ }
+ serverStates.Lock()
+ delete(serverStates.byID, serverID)
+ serverStates.Unlock()
+}
+
+// CurrentServerState returns the listener state recorded for serverID.
+// A server this process has not started, stopped, or failed is configured.
+func CurrentServerState(serverID string) ServerState {
+ serverStates.RLock()
+ enabled := serverStates.enabled
+ state, ok := serverStates.byID[serverID]
+ serverStates.RUnlock()
+ if !enabled || !ok || !state.valid() {
+ return ServerStateConfigured
+ }
+ return state
+}
diff --git a/dhcp/server_state_test.go b/dhcp/server_state_test.go
new file mode 100644
index 0000000..16515c3
--- /dev/null
+++ b/dhcp/server_state_test.go
@@ -0,0 +1,86 @@
+package dhcp
+
+import (
+ "context"
+ "errors"
+ "net"
+ "sync"
+ "testing"
+
+ "ignite/db"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestServerStateTracksTheListener(t *testing.T) {
+ prev := listenDHCP
+ t.Cleanup(func() { listenDHCP = prev })
+
+ const id = "listener-state-server"
+ t.Cleanup(func() { ClearServerState(id) })
+
+ repo := &memServerRepo{servers: map[string]*Server{
+ id: {ID: id, IP: net.ParseIP("10.1.0.1"), Started: true},
+ }}
+ svc := NewDHCPServerService(repo, nil)
+ ctx := context.Background()
+
+ // A persisted Started flag from a previous process is not a live listener.
+ require.Equal(t, ServerStateConfigured, CurrentServerState(id))
+
+ listenDHCP = func(string, string) (net.PacketConn, error) {
+ return nil, errors.New("bind refused")
+ }
+ err := svc.StartServer(ctx, id)
+ require.Error(t, err)
+ require.Equal(t, ServerStateStopped, CurrentServerState(id))
+
+ listenDHCP = func(string, string) (net.PacketConn, error) {
+ return net.ListenPacket("udp4", "127.0.0.1:0")
+ }
+ require.NoError(t, svc.StartServer(ctx, id))
+ t.Cleanup(func() { _ = svc.StopServer(context.Background(), id) })
+ require.Equal(t, ServerStateRunning, CurrentServerState(id))
+
+ require.NoError(t, svc.StopServer(ctx, id))
+ require.Equal(t, ServerStateStopped, CurrentServerState(id))
+}
+
+func TestNoteServerStateIgnoresUnknownValues(t *testing.T) {
+ const id = "note-state-server"
+ t.Cleanup(func() { ClearServerState(id) })
+ NoteServerState(id, ServerState("badge-success"))
+ require.Equal(t, ServerStateConfigured, CurrentServerState(id))
+ NoteServerState("", ServerStateRunning)
+ require.Equal(t, ServerStateConfigured, CurrentServerState(""))
+}
+
+type memServerRepo struct {
+ mu sync.Mutex
+ servers map[string]*Server
+}
+
+func (m *memServerRepo) Save(_ context.Context, server *Server) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *server
+ m.servers[server.ID] = &cp
+ return nil
+}
+
+func (m *memServerRepo) Get(_ context.Context, id string) (*Server, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ server, ok := m.servers[id]
+ if !ok {
+ return nil, db.ErrNotFound
+ }
+ cp := *server
+ return &cp, nil
+}
+
+func (m *memServerRepo) GetAll(context.Context) ([]*Server, error) { return nil, nil }
+func (m *memServerRepo) Delete(context.Context, string) error { return nil }
+func (m *memServerRepo) GetByIP(context.Context, net.IP) (*Server, error) {
+ return nil, db.ErrNotFound
+}
diff --git a/dhcp/service.go b/dhcp/service.go
index ed359fd..26a0f41 100644
--- a/dhcp/service.go
+++ b/dhcp/service.go
@@ -15,6 +15,10 @@ import (
"github.com/google/uuid"
)
+// ErrDHCPDisabled is returned when a start is requested while DHCP_ENABLED
+// is off. The listener is not bound.
+var ErrDHCPDisabled = errors.New("DHCP is disabled")
+
// sharedAllocMu serializes IP allocation across the DHCP packet path
// (ProtocolHandler) and the service path (DHCPLeaseService). Both services
// are constructed independently, so the shared mutex lives at package scope;
@@ -148,7 +152,7 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
return fmt.Errorf("failed to get server: %w", err)
}
- if server.Started {
+ if s.hasHandler(serverID) {
return fmt.Errorf("server is already running")
}
@@ -156,8 +160,12 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
// path never pays for config.LoadDefault() per packet.
appCfg, err := config.LoadDefault()
if err != nil {
+ NoteServerState(serverID, ServerStateStopped)
return fmt.Errorf("failed to load application config: %w", err)
}
+ if !appCfg.DHCP.Enabled {
+ return ErrDHCPDisabled
+ }
bootFiles := ProtocolBootFiles{
BiosFile: appCfg.DHCP.BiosFile,
EFIFile: appCfg.DHCP.EFIFile,
@@ -169,6 +177,7 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
WithBootFiles(bootFiles),
)
if err := handler.Start(); err != nil {
+ NoteServerState(serverID, ServerStateStopped)
return fmt.Errorf("failed to start DHCP handler: %w", err)
}
@@ -186,12 +195,21 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
s.handlersMu.Lock()
delete(s.handlers, serverID)
s.handlersMu.Unlock()
+ NoteServerState(serverID, ServerStateStopped)
return fmt.Errorf("failed to update server state: %w", err)
}
+ NoteServerState(serverID, ServerStateRunning)
return nil
}
+// hasHandler reports whether this process has a live listener for serverID.
+func (s *DHCPServerService) hasHandler(serverID string) bool {
+ s.handlersMu.RLock()
+ defer s.handlersMu.RUnlock()
+ return s.handlers[serverID] != nil
+}
+
// StopServer stops a DHCP server
func (s *DHCPServerService) StopServer(ctx context.Context, serverID string) error {
server, err := s.serverRepo.Get(ctx, serverID)
@@ -199,24 +217,27 @@ func (s *DHCPServerService) StopServer(ctx context.Context, serverID string) err
return fmt.Errorf("failed to get server: %w", err)
}
- if !server.Started {
- return fmt.Errorf("server is not running")
- }
-
- // Stop protocol handler
s.handlersMu.Lock()
- if handler, exists := s.handlers[serverID]; exists {
- if err := handler.Stop(); err != nil {
+ handler, listening := s.handlers[serverID]
+ if listening {
+ if err := handler.Stop(); err != nil && !errors.Is(err, ErrHandlerNotRunning) {
log.Printf("Error stopping DHCP handler: %v", err)
}
delete(s.handlers, serverID)
}
s.handlersMu.Unlock()
+ if !listening && !server.Started {
+ return fmt.Errorf("server is not running")
+ }
+
// Update server state
server.Started = false
server.UpdatedAt = time.Now()
+ // The socket is already closed. Record stopped even when the row cannot
+ // be saved, so the APIs do not keep reporting running.
+ NoteServerState(serverID, ServerStateStopped)
if err := s.serverRepo.Save(ctx, server); err != nil {
return fmt.Errorf("failed to update server state: %w", err)
}
@@ -226,23 +247,63 @@ func (s *DHCPServerService) StopServer(ctx context.Context, serverID string) err
// DeleteServer deletes a DHCP server and all its leases
func (s *DHCPServerService) DeleteServer(ctx context.Context, serverID string) error {
- // Stop server if running
+ // Stop server if running. The listener is down from here on, whether or
+ // not the row delete succeeds.
s.handlersMu.Lock()
if handler, exists := s.handlers[serverID]; exists {
_ = handler.Stop()
delete(s.handlers, serverID)
+ NoteServerState(serverID, ServerStateStopped)
}
s.handlersMu.Unlock()
- // Delete all leases for this server
- if err := s.leaseRepo.DeleteByServerID(ctx, serverID); err != nil {
- return fmt.Errorf("failed to delete server leases: %w", err)
+ // The handler is stopped before taking allocMu so an in-flight packet
+ // can finish its own allocMu -> lockLease sequence. Deletes then take
+ // the same order: the pool lock, then each lease lock, with a re-read.
+ s.allocMu.Lock()
+ defer s.allocMu.Unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ leases, err := s.leaseRepo.GetByServerID(ctx, serverID)
+ if err != nil {
+ return fmt.Errorf("failed to list server leases: %w", err)
+ }
+ for _, lease := range leases {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if lease == nil {
+ continue
+ }
+ unlock, lerr := lockLease(ctx, lease.MAC)
+ if lerr != nil {
+ return lerr
+ }
+ fresh, gerr := s.leaseRepo.Get(ctx, lease.ID)
+ if errors.Is(gerr, db.ErrNotFound) {
+ unlock()
+ continue
+ }
+ if gerr != nil {
+ unlock()
+ return fmt.Errorf("failed to read lease %s: %w", lease.ID, gerr)
+ }
+ if fresh.ServerID == serverID {
+ gerr = s.leaseRepo.Delete(ctx, fresh.ID)
+ }
+ unlock()
+ if gerr != nil {
+ return fmt.Errorf("failed to delete lease %s: %w", lease.ID, gerr)
+ }
}
// Delete server
if err := s.serverRepo.Delete(ctx, serverID); err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
+ ClearServerState(serverID)
return nil
}
diff --git a/dhcp/services_test.go b/dhcp/services_test.go
index 610bbee..b1100c7 100644
--- a/dhcp/services_test.go
+++ b/dhcp/services_test.go
@@ -79,7 +79,9 @@ func TestDHCPLeaseService_UpdateLease(t *testing.T) {
},
}
- // Mock expectations
+ // Mock expectations. UpdateLease re-reads the row under the allocation
+ // lock before saving, so a concurrent renewal is not clobbered.
+ mockLeaseRepo.On("Get", ctx, lease.ID).Return(lease, nil)
mockLeaseRepo.On("Save", ctx, lease).Return(nil)
// Execute
diff --git a/dhcp/summary.go b/dhcp/summary.go
new file mode 100644
index 0000000..525b06c
--- /dev/null
+++ b/dhcp/summary.go
@@ -0,0 +1,274 @@
+package dhcp
+
+import (
+ "net"
+ "sort"
+ "time"
+)
+
+// LeaseBucket is the single summary class a lease belongs to.
+type LeaseBucket string
+
+const (
+ // BucketReserved is a static reservation. It wins over expiry: a
+ // reservation still holds its address after the expiry timestamp, and
+ // cleanup does not delete it.
+ BucketReserved LeaseBucket = "reserved"
+ // BucketExpired is a non-reserved lease whose expiry is in the past.
+ BucketExpired LeaseBucket = "expired"
+ // BucketActive is a non-reserved lease that has not expired. The address
+ // is still held, whatever the provisioning state (failed, offline, and
+ // complete included).
+ BucketActive LeaseBucket = "active"
+)
+
+// LeaseCounts is a partition of leases into the three summary buckets.
+// Every counted lease contributes to exactly one field.
+type LeaseCounts struct {
+ Active int `json:"active"`
+ Expired int `json:"expired"`
+ Reserved int `json:"reserved"`
+}
+
+// ProvisionCounts totals leases by provisioning state.
+//
+// InFlight and Failed are independent of ClassifyLease. A lease still
+// belongs to exactly one of Active, Reserved, or Expired, and it can also
+// be in flight or failed. A reserved lease in StateImaging counts as both
+// reserved and in flight. An expired lease in StateFailed counts as both
+// expired and failed.
+//
+// InFlight counts StatePXERequested, StateBooting, StateImaging,
+// StateImaged, and StateConfiguring. Failed counts StateFailed.
+// Nil leases are skipped.
+type ProvisionCounts struct {
+ InFlight int `json:"in_flight"`
+ Failed int `json:"failed"`
+}
+
+// PoolStat is one DHCP server's address pool.
+//
+// Server is the server's interface address (its configured IP). Used counts
+// leases on that server that occupy an address (reserved, or not expired).
+// Total is the server's LeaseRange. Free is Total-Used, floored at zero
+// when the stored leases exceed the configured range.
+type PoolStat struct {
+ Server string `json:"server"`
+ Used int `json:"used"`
+ Free int `json:"free"`
+ Total int `json:"total"`
+ // Status is the listener state from CurrentServerState:
+ // running, stopped, or configured.
+ Status ServerState `json:"status"`
+ // InFlight and Failed follow CountProvision. They are independent of
+ // Used, so a lease can occupy an address and also be in flight or failed.
+ InFlight int `json:"in_flight"`
+ Failed int `json:"failed"`
+}
+
+// DashboardSummary is the Overview card payload.
+// Active, Expired, and Reserved are a partition from CountLeases.
+// InFlight and Failed are separate totals from CountProvision: a lease
+// can be counted there and in one of the partition fields.
+// Counts are global across every server. Pools is one entry per server,
+// ordered by interface address. Pools is never nil.
+type DashboardSummary struct {
+ Active int `json:"active"`
+ Expired int `json:"expired"`
+ Reserved int `json:"reserved"`
+ InFlight int `json:"in_flight"`
+ Failed int `json:"failed"`
+ Pools []PoolStat `json:"pools"`
+ // Free, Used, and PoolTotal sum the pool rows for the Overview strip.
+ // They are presentation totals, not part of the JSON summary.
+ Free int `json:"-"`
+ Used int `json:"-"`
+ PoolTotal int `json:"-"`
+ // Mode is the signed-in user's UI density. Page render fills it.
+ // It is not part of the JSON summary.
+ Mode string `json:"-"`
+}
+
+// PoolOccupancy counts leases that occupy an address.
+// Used is reserved or unexpired. Expired non-reserved leases do not count.
+// Nil entries are skipped. This is the only occupancy rule; the dashboard
+// pool row and the DHCP page both call it.
+func PoolOccupancy(leases []*Lease, now time.Time) int {
+ used := 0
+ for _, lease := range leases {
+ if lease != nil && lease.OccupiesAddress(now) {
+ used++
+ }
+ }
+ return used
+}
+
+// ClassifyLease assigns a lease to exactly one bucket.
+//
+// Precedence, first match wins:
+// 1. reserved, when Reserved is true, even if Expiry is already past
+// 2. expired, when the lease is not reserved and now is after Expiry
+// 3. active, otherwise
+func ClassifyLease(lease *Lease, now time.Time) LeaseBucket {
+ if lease == nil {
+ return BucketExpired
+ }
+ if lease.Reserved {
+ return BucketReserved
+ }
+ if now.After(lease.Expiry) {
+ return BucketExpired
+ }
+ return BucketActive
+}
+
+// CountLeases partitions leases with ClassifyLease. Nil entries are skipped.
+func CountLeases(leases []*Lease, now time.Time) LeaseCounts {
+ var counts LeaseCounts
+ for _, lease := range leases {
+ if lease == nil {
+ continue
+ }
+ switch ClassifyLease(lease, now) {
+ case BucketReserved:
+ counts.Reserved++
+ case BucketExpired:
+ counts.Expired++
+ default:
+ counts.Active++
+ }
+ }
+ return counts
+}
+
+// CountProvision totals in-flight and failed leases.
+// See ProvisionCounts for which states count, and for how these totals
+// sit beside the Active, Reserved, and Expired partition.
+func CountProvision(leases []*Lease) ProvisionCounts {
+ var counts ProvisionCounts
+ for _, lease := range leases {
+ if lease == nil {
+ continue
+ }
+ if lease.inProgress() {
+ counts.InFlight++
+ }
+ if lease.State == StateFailed {
+ counts.Failed++
+ }
+ }
+ return counts
+}
+
+// PoolStatForServer builds the pool row for one server.
+// leases should be that server's leases; others are ignored.
+func PoolStatForServer(server *Server, leases []*Lease, now time.Time) PoolStat {
+ stat := PoolStat{Server: serverLabel(server)}
+ if server != nil && server.LeaseRange > 0 {
+ stat.Total = server.LeaseRange
+ }
+ var owned []*Lease
+ for _, lease := range leases {
+ if lease == nil || server == nil || lease.ServerID != server.ID {
+ continue
+ }
+ owned = append(owned, lease)
+ }
+ stat.Used = PoolOccupancy(owned, now)
+ stat.Free = stat.Total - stat.Used
+ if stat.Free < 0 {
+ stat.Free = 0
+ }
+ provision := CountProvision(owned)
+ stat.InFlight = provision.InFlight
+ stat.Failed = provision.Failed
+ if server != nil {
+ stat.Status = CurrentServerState(server.ID)
+ } else {
+ stat.Status = ServerStateConfigured
+ }
+ return stat
+}
+
+// BuildDashboardSummary assembles the Overview payload.
+// leasesByServer maps a server ID to that server's leases. Missing entries
+// are an empty pool. Counts include only leases that belong to a known server.
+func BuildDashboardSummary(servers []*Server, leasesByServer map[string][]*Lease, now time.Time) DashboardSummary {
+ summary := DashboardSummary{Pools: make([]PoolStat, 0, len(servers))}
+ ordered := append([]*Server(nil), servers...)
+ sort.Slice(ordered, func(i, j int) bool {
+ return compareServerLabels(ordered[i], ordered[j]) < 0
+ })
+
+ var all []*Lease
+ for _, server := range ordered {
+ if server == nil {
+ continue
+ }
+ leases := leasesByServer[server.ID]
+ summary.Pools = append(summary.Pools, PoolStatForServer(server, leases, now))
+ all = append(all, leases...)
+ }
+ counts := CountLeases(all, now)
+ summary.Active = counts.Active
+ summary.Expired = counts.Expired
+ summary.Reserved = counts.Reserved
+ provision := CountProvision(all)
+ summary.InFlight = provision.InFlight
+ summary.Failed = provision.Failed
+ for _, pool := range summary.Pools {
+ summary.Free += pool.Free
+ summary.Used += pool.Used
+ summary.PoolTotal += pool.Total
+ }
+ return summary
+}
+
+// ServerIPLabel is the address shown for a server on the dashboard.
+// An unaddressed server falls back to its ID.
+func ServerIPLabel(server *Server) string {
+ return serverLabel(server)
+}
+
+func serverLabel(server *Server) string {
+ if server == nil || server.IP == nil {
+ if server == nil {
+ return ""
+ }
+ return server.ID
+ }
+ if ip4 := server.IP.To4(); ip4 != nil {
+ return ip4.String()
+ }
+ return server.IP.String()
+}
+
+func compareServerLabels(a, b *Server) int {
+ if a == nil || a.IP == nil {
+ if b == nil || b.IP == nil {
+ return 0
+ }
+ return 1
+ }
+ if b == nil || b.IP == nil {
+ return -1
+ }
+ return compareIPs(a.IP, b.IP)
+}
+
+func compareIPs(a, b net.IP) int {
+ a4 := a.To4()
+ b4 := b.To4()
+ if a4 == nil || b4 == nil {
+ return 0
+ }
+ for i := 0; i < 4; i++ {
+ if a4[i] < b4[i] {
+ return -1
+ }
+ if a4[i] > b4[i] {
+ return 1
+ }
+ }
+ return 0
+}
diff --git a/dhcp/summary_test.go b/dhcp/summary_test.go
new file mode 100644
index 0000000..1fce885
--- /dev/null
+++ b/dhcp/summary_test.go
@@ -0,0 +1,272 @@
+package dhcp
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClassifyLeasePrecedence(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ past := now.Add(-time.Hour)
+ future := now.Add(time.Hour)
+
+ tests := []struct {
+ name string
+ lease *Lease
+ bucket LeaseBucket
+ }{
+ {
+ name: "reserved and unexpired",
+ lease: &Lease{Reserved: true, Expiry: future, State: StateAssigned},
+ bucket: BucketReserved,
+ },
+ {
+ name: "reserved wins over a past expiry",
+ lease: &Lease{Reserved: true, Expiry: past, State: StateFailed},
+ bucket: BucketReserved,
+ },
+ {
+ name: "expired non-reserved",
+ lease: &Lease{Reserved: false, Expiry: past, State: StateAssigned},
+ bucket: BucketExpired,
+ },
+ {
+ name: "active holds the address regardless of provisioning state",
+ lease: &Lease{Reserved: false, Expiry: future, State: StateFailed},
+ bucket: BucketActive,
+ },
+ {
+ name: "complete and unexpired is active",
+ lease: &Lease{Reserved: false, Expiry: future, State: StateComplete},
+ bucket: BucketActive,
+ },
+ {
+ name: "expiry equal to now is still active",
+ lease: &Lease{Reserved: false, Expiry: now, State: StateAssigned},
+ bucket: BucketActive,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.bucket, ClassifyLease(tt.lease, now))
+ })
+ }
+}
+
+func TestCountLeasesPartitionsExactlyOnce(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ leases := []*Lease{
+ {Reserved: true, Expiry: now.Add(time.Hour)},
+ {Reserved: true, Expiry: now.Add(-time.Hour)},
+ {Expiry: now.Add(-time.Minute), State: StateAssigned},
+ {Expiry: now.Add(time.Hour), State: StateFailed},
+ {Expiry: now.Add(time.Hour), State: StateComplete},
+ nil,
+ }
+ counts := CountLeases(leases, now)
+ assert.Equal(t, 2, counts.Reserved)
+ assert.Equal(t, 1, counts.Expired)
+ assert.Equal(t, 2, counts.Active)
+ assert.Equal(t, 5, counts.Active+counts.Expired+counts.Reserved)
+}
+
+func TestCountProvisionStates(t *testing.T) {
+ inFlight := []LeaseState{
+ StatePXERequested,
+ StateBooting,
+ StateImaging,
+ StateImaged,
+ StateConfiguring,
+ }
+ var leases []*Lease
+ for _, state := range inFlight {
+ leases = append(leases, &Lease{State: state})
+ }
+ leases = append(leases,
+ &Lease{State: StateFailed},
+ &Lease{State: StateAssigned},
+ &Lease{State: StateComplete},
+ &Lease{State: StateOffline},
+ nil,
+ )
+ counts := CountProvision(leases)
+ assert.Equal(t, len(inFlight), counts.InFlight)
+ assert.Equal(t, 1, counts.Failed)
+}
+
+func TestProvisionCountsIgnoreOccupancyPrecedence(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ reservedImaging := &Lease{Reserved: true, Expiry: now.Add(-time.Hour), State: StateImaging}
+ expiredFailed := &Lease{Expiry: now.Add(-time.Hour), State: StateFailed}
+ assert.Equal(t, BucketReserved, ClassifyLease(reservedImaging, now))
+ assert.Equal(t, BucketExpired, ClassifyLease(expiredFailed, now))
+
+ counts := CountProvision([]*Lease{reservedImaging, expiredFailed})
+ assert.Equal(t, 1, counts.InFlight)
+ assert.Equal(t, 1, counts.Failed)
+
+ partition := CountLeases([]*Lease{reservedImaging, expiredFailed}, now)
+ assert.Equal(t, 1, partition.Reserved)
+ assert.Equal(t, 1, partition.Expired)
+ assert.Equal(t, 0, partition.Active)
+}
+
+func TestPoolStatForServer(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ server := &Server{
+ ID: "srv-1",
+ IP: net.ParseIP("192.168.1.10"),
+ LeaseRange: 10,
+ }
+ leases := []*Lease{
+ {ServerID: "srv-1", Reserved: true, Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.1")},
+ {ServerID: "srv-1", Reserved: true, Expiry: now.Add(time.Hour), IP: net.ParseIP("192.168.1.2")},
+ {ServerID: "srv-1", Expiry: now.Add(time.Hour), IP: net.ParseIP("192.168.1.3")},
+ {ServerID: "srv-1", Expiry: now.Add(time.Hour), IP: net.ParseIP("192.168.1.4")},
+ {ServerID: "srv-1", Expiry: now.Add(time.Hour), IP: net.ParseIP("192.168.1.5")},
+ {ServerID: "srv-1", Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.6")},
+ {ServerID: "other", Expiry: now.Add(time.Hour), IP: net.ParseIP("10.0.0.2")},
+ }
+
+ stat := PoolStatForServer(server, leases, now)
+ assert.Equal(t, "192.168.1.10", stat.Server)
+ assert.Equal(t, 10, stat.Total)
+ // Two reservations (one already past expiry) plus three live leases.
+ assert.Equal(t, 5, stat.Used)
+ assert.Equal(t, 5, stat.Free)
+}
+
+func TestBuildDashboardSummaryOrdersPoolsAndCounts(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ low := &Server{ID: "b", IP: net.ParseIP("10.0.0.10"), LeaseRange: 4}
+ high := &Server{ID: "a", IP: net.ParseIP("192.168.1.10"), LeaseRange: 8}
+ byServer := map[string][]*Lease{
+ "a": {
+ {ServerID: "a", Reserved: true, Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.20")},
+ {ServerID: "a", Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.21")},
+ },
+ "b": {
+ {ServerID: "b", Expiry: now.Add(time.Hour), IP: net.ParseIP("10.0.0.20")},
+ },
+ }
+
+ summary := BuildDashboardSummary([]*Server{high, low}, byServer, now)
+ require.Len(t, summary.Pools, 2)
+ assert.Equal(t, "10.0.0.10", summary.Pools[0].Server)
+ assert.Equal(t, 1, summary.Pools[0].Used)
+ assert.Equal(t, 3, summary.Pools[0].Free)
+ assert.Equal(t, 4, summary.Pools[0].Total)
+ assert.Equal(t, "192.168.1.10", summary.Pools[1].Server)
+ assert.Equal(t, 1, summary.Pools[1].Used)
+ assert.Equal(t, 7, summary.Pools[1].Free)
+
+ assert.Equal(t, 1, summary.Active)
+ assert.Equal(t, 1, summary.Expired)
+ assert.Equal(t, 1, summary.Reserved)
+ assert.Equal(t, 0, summary.InFlight)
+ assert.Equal(t, 0, summary.Failed)
+}
+
+func TestBuildDashboardSummaryCountsInFlightAndFailed(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ server := &Server{ID: "srv", IP: net.ParseIP("10.1.0.1"), LeaseRange: 8}
+ owned := []*Lease{
+ {ServerID: "srv", Reserved: true, Expiry: now.Add(-time.Hour), State: StateImaging, IP: net.ParseIP("10.1.0.10")},
+ {ServerID: "srv", Expiry: now.Add(-time.Hour), State: StateFailed, IP: net.ParseIP("10.1.0.11")},
+ {ServerID: "srv", Expiry: now.Add(time.Hour), State: StateBooting, IP: net.ParseIP("10.1.0.12")},
+ }
+ summary := BuildDashboardSummary([]*Server{server}, map[string][]*Lease{"srv": owned}, now)
+ assert.Equal(t, 1, summary.Active)
+ assert.Equal(t, 1, summary.Expired)
+ assert.Equal(t, 1, summary.Reserved)
+ assert.Equal(t, 2, summary.InFlight)
+ assert.Equal(t, 1, summary.Failed)
+ require.Len(t, summary.Pools, 1)
+ assert.Equal(t, 2, summary.Pools[0].InFlight)
+ assert.Equal(t, 1, summary.Pools[0].Failed)
+ assert.Equal(t, 2, summary.Pools[0].Used)
+
+ withOther := append(append([]*Lease{}, owned...), &Lease{
+ ServerID: "other",
+ Expiry: now.Add(time.Hour),
+ State: StateFailed,
+ IP: net.ParseIP("10.9.0.1"),
+ })
+ stat := PoolStatForServer(server, withOther, now)
+ assert.Equal(t, 2, stat.InFlight)
+ assert.Equal(t, 1, stat.Failed)
+}
+
+func TestPoolFreeFloorsAtZero(t *testing.T) {
+ now := time.Now()
+ server := &Server{ID: "s", IP: net.ParseIP("192.168.0.1"), LeaseRange: 1}
+ leases := []*Lease{
+ {ServerID: "s", Expiry: now.Add(time.Hour), IP: net.ParseIP("192.168.0.2")},
+ {ServerID: "s", Reserved: true, Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.0.3")},
+ }
+ stat := PoolStatForServer(server, leases, now)
+ assert.Equal(t, 2, stat.Used)
+ assert.Equal(t, 0, stat.Free)
+ assert.Equal(t, 1, stat.Total)
+}
+
+func TestPoolOccupancyIgnoresExpired(t *testing.T) {
+ now := time.Now()
+ leases := []*Lease{
+ {IP: net.ParseIP("10.0.0.1"), Expiry: now.Add(time.Hour)},
+ {IP: net.ParseIP("10.0.0.2"), Expiry: now.Add(-time.Hour)},
+ {IP: net.ParseIP("10.0.0.3"), Reserved: true, Expiry: now.Add(-time.Hour)},
+ nil,
+ }
+ assert.Equal(t, 2, PoolOccupancy(leases, now))
+}
+
+func TestCountProvisionIncludesImaged(t *testing.T) {
+ leases := []*Lease{
+ {State: StatePXERequested},
+ {State: StateBooting},
+ {State: StateImaging},
+ {State: StateConfiguring},
+ {State: StateFailed},
+ {State: StateFailed, Reserved: true},
+ {State: StateAssigned},
+ {State: StateComplete},
+ {State: StateImaged},
+ {State: StateOffline},
+ nil,
+ }
+ got := CountProvision(leases)
+ assert.Equal(t, 5, got.InFlight)
+ assert.Equal(t, 2, got.Failed)
+}
+
+func TestBuildDashboardSummaryDerivesInstallOutcomes(t *testing.T) {
+ now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
+ server := &Server{ID: "a", IP: net.ParseIP("192.168.1.10"), LeaseRange: 20}
+ byServer := map[string][]*Lease{
+ "a": {
+ {ServerID: "a", Expiry: now.Add(time.Hour), State: StateBooting, IP: net.ParseIP("192.168.1.20")},
+ {ServerID: "a", Expiry: now.Add(time.Hour), State: StateFailed, IP: net.ParseIP("192.168.1.21")},
+ {ServerID: "a", Reserved: true, Expiry: now.Add(time.Hour), State: StateImaging, IP: net.ParseIP("192.168.1.22")},
+ },
+ }
+ summary := BuildDashboardSummary([]*Server{server}, byServer, now)
+ assert.Equal(t, 2, summary.InFlight)
+ assert.Equal(t, 1, summary.Failed)
+ assert.Equal(t, 17, summary.Free)
+ assert.Equal(t, 3, summary.Used)
+ assert.Equal(t, 20, summary.PoolTotal)
+}
+
+func TestOccupiesAddressReservedPastExpiry(t *testing.T) {
+ now := time.Now()
+ reserved := &Lease{Reserved: true, Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.9")}
+ expired := &Lease{Expiry: now.Add(-time.Hour), IP: net.ParseIP("192.168.1.10")}
+ assert.True(t, reserved.OccupiesAddress(now))
+ assert.False(t, expired.OccupiesAddress(now))
+}
diff --git a/dhcp/token.go b/dhcp/token.go
new file mode 100644
index 0000000..62aa6c5
--- /dev/null
+++ b/dhcp/token.go
@@ -0,0 +1,47 @@
+package dhcp
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "strings"
+)
+
+// NewProvisionToken returns a fresh per-lease callback secret (32 random
+// bytes, hex-encoded). Callers store it on the lease and hand it to the
+// host being provisioned. It is not an admin session token.
+func NewProvisionToken() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate provision token: %w", err)
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+// CanonicalMAC parses mac and returns the lowercase colon-separated form
+// used as the lease key (the same form net.HardwareAddr.String produces
+// for a 48-bit address).
+func CanonicalMAC(mac string) (string, error) {
+ hw, err := net.ParseMAC(strings.TrimSpace(mac))
+ if err != nil {
+ return "", fmt.Errorf("invalid MAC address %q", mac)
+ }
+ if len(hw) != 6 {
+ return "", fmt.Errorf("MAC address %q is not 48 bits", mac)
+ }
+ return hw.String(), nil
+}
+
+// ensureProvisionToken sets a token when the lease does not have one.
+func ensureProvisionToken(lease *Lease) error {
+ if lease == nil || lease.ProvisionToken != "" {
+ return nil
+ }
+ token, err := NewProvisionToken()
+ if err != nil {
+ return err
+ }
+ lease.ProvisionToken = token
+ return nil
+}
diff --git a/dockerfile b/dockerfile
index 7c28f96..0080427 100755
--- a/dockerfile
+++ b/dockerfile
@@ -41,8 +41,13 @@ WORKDIR /app
COPY --from=builder /build/ignite .
COPY --chown=ignite:ignite ./public ./public
-# Create data directory for database
+# Create the data directory before the volume so a new named volume is
+# owned by ignite (uid 1001). The database and Syslinux cache live here.
RUN mkdir -p /app/data && chown ignite:ignite /app/data
+VOLUME /app/data
+
+# Declared after COPY so a new named volume is seeded from ./public/tftp.
+VOLUME /app/public/tftp
# Switch to non-root user
USER ignite
@@ -50,6 +55,6 @@ USER ignite
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD curl -f http://localhost:8080/status || exit 1
+ CMD curl -f http://localhost:${HTTP_PORT}/status || exit 1
CMD ["./ignite"]
\ No newline at end of file
diff --git a/handlers/auth.go b/handlers/auth.go
index 31127e9..528a829 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"log"
"net/http"
+ "os"
"strings"
"sync"
"time"
@@ -19,13 +20,20 @@ var (
defaultPassword = "admin"
)
-// sessionStore holds the server-side session table: token -> expiry.
+// sessionEntry is one server-side login. The CSRF token is bound to the
+// session and is not stored in a cookie, so a cross-site form cannot read it.
+type sessionEntry struct {
+ expiry time.Time
+ csrf string
+}
+
+// sessionStore holds the server-side session table: token -> entry.
// A session is valid only if its token is present here and not expired,
// so logout (delete) and expiry are enforced server-side.
var sessionStore = struct {
sync.RWMutex
- tokens map[string]time.Time
-}{tokens: make(map[string]time.Time)}
+ tokens map[string]sessionEntry
+}{tokens: make(map[string]sessionEntry)}
// sessionTTL is how long a login session stays valid.
const sessionTTL = 24 * time.Hour
@@ -49,14 +57,22 @@ func generateSessionToken() (string, error) {
return hex.EncodeToString(b), nil
}
-// createSession mints a new random token, stores it server-side, and returns it.
+// createSession mints a new random token and a per-session CSRF token,
+// stores them server-side, and returns the session token.
func createSession() (string, error) {
token, err := generateSessionToken()
if err != nil {
return "", err
}
+ csrf, err := generateSessionToken()
+ if err != nil {
+ return "", err
+ }
sessionStore.Lock()
- sessionStore.tokens[token] = time.Now().Add(sessionTTL)
+ sessionStore.tokens[token] = sessionEntry{
+ expiry: time.Now().Add(sessionTTL),
+ csrf: csrf,
+ }
sessionStore.Unlock()
return token, nil
}
@@ -77,26 +93,71 @@ func validSession(token string) bool {
return false
}
sessionStore.RLock()
- expiry, ok := sessionStore.tokens[token]
+ entry, ok := sessionStore.tokens[token]
sessionStore.RUnlock()
if !ok {
return false
}
- if time.Now().After(expiry) {
+ if time.Now().After(entry.expiry) {
destroySession(token)
return false
}
return true
}
-// sessionCookie builds the session cookie with secure flags.
-func sessionCookie(token string, expired bool) *http.Cookie {
+// sessionCSRF returns the CSRF token bound to a live session.
+// An unknown or expired session returns an empty string.
+func sessionCSRF(token string) string {
+ if !validSession(token) {
+ return ""
+ }
+ sessionStore.RLock()
+ entry, ok := sessionStore.tokens[token]
+ sessionStore.RUnlock()
+ if !ok {
+ return ""
+ }
+ return entry.csrf
+}
+
+// cookieSecure reports whether a cookie should set the Secure flag.
+// COOKIE_SECURE is auto (the default), true, or false. auto, and any other
+// value, sets Secure only when the request arrived on TLS or when
+// X-Forwarded-Proto is https. The CSRF token is not a cookie.
+func cookieSecure(r *http.Request) bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("COOKIE_SECURE"))) {
+ case "true":
+ return true
+ case "false":
+ return false
+ default:
+ return requestIsHTTPS(r)
+ }
+}
+
+// requestIsHTTPS reports a TLS connection or an https reverse-proxy hop.
+func requestIsHTTPS(r *http.Request) bool {
+ if r == nil {
+ return false
+ }
+ if r.TLS != nil {
+ return true
+ }
+ proto := r.Header.Get("X-Forwarded-Proto")
+ if i := strings.Index(proto, ","); i >= 0 {
+ proto = proto[:i]
+ }
+ return strings.EqualFold(strings.TrimSpace(proto), "https")
+}
+
+// sessionCookie builds the session cookie. Secure follows cookieSecure.
+func sessionCookie(r *http.Request, token string, expired bool) *http.Cookie {
c := &http.Cookie{
Name: "ignite_session",
Value: token,
Path: "/",
HttpOnly: true,
- Secure: true,
+ Secure: cookieSecure(r),
SameSite: http.SameSiteLaxMode,
}
if expired {
@@ -124,11 +185,11 @@ type LoginRequest struct {
Password string `json:"password"`
}
-// LoginResponse represents a login response
+// LoginResponse is the login and logout body. The session token stays in
+// the HttpOnly cookie and is not returned here.
type LoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
- Token string `json:"token,omitempty"`
}
// Login handles user login
@@ -190,13 +251,12 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
}
// Set session cookie with secure flags
- http.SetCookie(w, sessionCookie(token, false))
+ http.SetCookie(w, sessionCookie(r, token, false))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Success: true,
Message: "Login successful",
- Token: token,
})
}
@@ -207,7 +267,7 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
}
// Clear the session cookie
- http.SetCookie(w, sessionCookie("", true))
+ http.SetCookie(w, sessionCookie(r, "", true))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
@@ -288,7 +348,12 @@ func (h *AuthHandlers) LoginPage(w http.ResponseWriter, r *http.Request) {
return
}
- renderCachedTemplate(w, r, "login", nil, "Unable to render the login page")
+ // Mode stays empty so the preference layer can set it. The login shell
+ // falls back to cozy when the field is empty.
+ renderCachedTemplate(w, r, "login", struct {
+ Title string
+ Mode string
+ }{Title: "Ignite"}, "Unable to render the login page")
}
// isAuthenticated checks if the request carries a valid server-side session.
@@ -300,6 +365,48 @@ func isAuthenticated(r *http.Request) bool {
return validSession(cookie.Value)
}
+// isPublicAsset reports whether path is a static file the installer or the
+// browser may fetch without a session. Provision configs are excluded even
+// when they live under /public/provision/configs/.
+func isPublicAsset(path string) bool {
+ if !strings.HasPrefix(path, "/public/") {
+ return false
+ }
+ if strings.Contains(path, "/provision/configs/") {
+ return false
+ }
+ return true
+}
+
+// isRenderedConfig reports GET /configs/{type}/{name}. The handler, not the
+// middleware, decides whether this client may see the file.
+func isRenderedConfig(r *http.Request) bool {
+ if r.Method != http.MethodGet {
+ return false
+ }
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ return len(parts) == 3 && parts[0] == "configs" && parts[1] != "" && parts[2] != ""
+}
+
+// isHostCallback reports whether the request is a per-lease provisioning
+// callback (heartbeat or state), as opposed to the session-protected admin
+// route POST /dhcp/lease/state.
+func isHostCallback(r *http.Request) bool {
+ if r.Method != http.MethodPost {
+ return false
+ }
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ if len(parts) != 4 || parts[0] != "dhcp" || parts[1] != "lease" || parts[2] == "" {
+ return false
+ }
+ switch parts[3] {
+ case "heartbeat", "state":
+ return true
+ default:
+ return false
+ }
+}
+
// isAPIRequest reports whether the request expects a JSON 401 instead of a
// login redirect: anything under /api/ or explicitly asking for JSON.
func isAPIRequest(r *http.Request) bool {
@@ -313,21 +420,48 @@ func isAPIRequest(r *http.Request) bool {
// API/JSON clients get a 401 JSON response; browsers get a login redirect.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Skip auth for login page and auth endpoints
- if r.URL.Path == "/login" ||
- r.URL.Path == "/auth/login" ||
- r.URL.Path == "/auth/logout" {
+ // Login has no session yet, so it cannot present a CSRF token.
+ if r.URL.Path == "/login" || r.URL.Path == "/auth/login" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Logout stays reachable without a session. A live session must
+ // still present its CSRF token.
+ if r.URL.Path == "/auth/logout" {
+ if isAuthenticated(r) {
+ if message := csrfFailure(r); message != "" {
+ rejectCSRF(w, r, message)
+ return
+ }
+ }
next.ServeHTTP(w, r)
return
}
- // Skip auth for static files and PXE boot files (needed for network booting)
- if strings.HasPrefix(r.URL.Path, "/public/") ||
+ // Skip auth for static files and PXE boot files (needed for network booting).
+ // Rendered installer configs are not in that set: they carry the
+ // per-lease token and are served only by GET /configs/{type}/{name}.
+ if isPublicAsset(r.URL.Path) ||
strings.HasPrefix(r.URL.Path, "/tftp/serve") {
next.ServeHTTP(w, r)
return
}
+ // The installer fetches its config before it has the token. The
+ // handler allows the lease IP or a presented token, and 404s the rest.
+ if isRenderedConfig(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Host provisioning callbacks authenticate with a per-lease token
+ // inside the handler. They must not be redirected to the login page.
+ if isHostCallback(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
// Check authentication
if !isAuthenticated(r) {
if isAPIRequest(r) {
@@ -343,6 +477,11 @@ func AuthMiddleware(next http.Handler) http.Handler {
return
}
+ if message := csrfFailure(r); message != "" {
+ rejectCSRF(w, r, message)
+ return
+ }
+
next.ServeHTTP(w, r)
})
}
diff --git a/handlers/auth_integration_test.go b/handlers/auth_integration_test.go
index 170ef99..2e34a88 100644
--- a/handlers/auth_integration_test.go
+++ b/handlers/auth_integration_test.go
@@ -370,7 +370,7 @@ func TestSessionPersistence(t *testing.T) {
middlewareHandler := AuthMiddleware(testHandler)
- paths := []string{"/", "/dhcp", "/provision", "/status"}
+ paths := []string{"/", "/dhcp", "/provision", "/syslinux"}
for _, path := range paths {
t.Run("Session persistence for "+path, func(t *testing.T) {
diff --git a/handlers/auth_middleware_test.go b/handlers/auth_middleware_test.go
index 27f0eb3..abe9145 100644
--- a/handlers/auth_middleware_test.go
+++ b/handlers/auth_middleware_test.go
@@ -53,7 +53,6 @@ func TestAuthMiddleware_ProtectedPaths_Unauthenticated(t *testing.T) {
"/tftp",
"/osimages",
"/syslinux",
- "/status",
"/dhcp/servers",
"/provision/load-file",
}
@@ -94,7 +93,6 @@ func TestAuthMiddleware_ProtectedPaths_Authenticated(t *testing.T) {
"/tftp",
"/osimages",
"/syslinux",
- "/status",
}
// Create a simple handler that returns 200 OK
@@ -130,7 +128,7 @@ func TestAuthMiddleware_StaticFiles(t *testing.T) {
staticPaths := []string{
"/public/http/css/tailwind.css",
"/public/http/css/provision.css",
- "/public/http/img/Ignite_small.png",
+ "/public/http/img/favicon-32.png",
"/public/http/js/app.js",
"/public/provision/templates/kickstart/default.templ",
}
@@ -232,7 +230,11 @@ func TestAuthMiddleware_POSTRequests(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", tt.path, nil)
if tt.authenticated {
- req.AddCookie(testSessionCookie(t))
+ cookie := testSessionCookie(t)
+ req.Host = "example.com"
+ req.Header.Set("Origin", "http://example.com")
+ req.AddCookie(cookie)
+ req.Header.Set("X-CSRF-Token", sessionCSRF(cookie.Value))
}
w := httptest.NewRecorder()
@@ -393,8 +395,8 @@ func TestSessionCookie_SecureFlags(t *testing.T) {
if !sessionCookie.HttpOnly {
t.Error("Expected session cookie to be HttpOnly")
}
- if !sessionCookie.Secure {
- t.Error("Expected session cookie to be Secure")
+ if sessionCookie.Secure {
+ t.Error("plain HTTP in auto mode must not set Secure")
}
if sessionCookie.SameSite != http.SameSiteLaxMode {
t.Errorf("Expected SameSite=Lax, got %v", sessionCookie.SameSite)
diff --git a/handlers/auth_test.go b/handlers/auth_test.go
index 8e601db..71a365f 100644
--- a/handlers/auth_test.go
+++ b/handlers/auth_test.go
@@ -69,8 +69,8 @@ func TestAuthHandlers_Login_Success(t *testing.T) {
t.Error("Expected login to succeed")
}
- if response.Token == "" {
- t.Error("Expected token to be set")
+ if strings.Contains(w.Body.String(), `"token"`) {
+ t.Errorf("login JSON must not include the session token: %s", w.Body.String())
}
// Check if session cookie is set
diff --git a/handlers/bmc.go b/handlers/bmc.go
new file mode 100644
index 0000000..604000e
--- /dev/null
+++ b/handlers/bmc.go
@@ -0,0 +1,160 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/stmcginnis/gofish"
+ "github.com/stmcginnis/gofish/redfish"
+)
+
+// bmcRequestTimeout bounds a single Redfish connect-and-act sequence.
+const bmcRequestTimeout = 20 * time.Second
+
+var (
+ errNoBMCSystems = errors.New("no redfish systems")
+ errAmbiguousBMCSystems = errors.New("multiple redfish systems and no system id")
+ errBMCSystemNotFound = errors.New("redfish system not found")
+)
+
+// bmcSystem is one Redfish ComputerSystem. Tests use a fake.
+type bmcSystem interface {
+ ID() string
+ PowerState() redfish.PowerState
+ SetBoot(redfish.Boot) error
+ Reset(redfish.ResetType) error
+}
+
+// bmcSession is a connected Redfish service.
+type bmcSession interface {
+ Logout() error
+ Systems() ([]bmcSystem, error)
+}
+
+// bmcDialer opens a Redfish session. verify checks the BMC certificate.
+// False accepts a self-signed certificate.
+type bmcDialer func(ctx context.Context, endpoint, username, password string, verify bool) (bmcSession, error)
+
+// selectBMCSystem picks the ComputerSystem to operate on.
+// A non-empty id must match exactly one system. With no id, the BMC must
+// expose exactly one system; more than one is an error rather than systems[0].
+func selectBMCSystem(systems []bmcSystem, wantID string) (bmcSystem, error) {
+ present := make([]bmcSystem, 0, len(systems))
+ for _, system := range systems {
+ if system != nil {
+ present = append(present, system)
+ }
+ }
+ if len(present) == 0 {
+ return nil, errNoBMCSystems
+ }
+
+ wantID = strings.TrimSpace(wantID)
+ if wantID != "" {
+ var match bmcSystem
+ for _, system := range present {
+ if system.ID() == wantID {
+ if match != nil {
+ return nil, errAmbiguousBMCSystems
+ }
+ match = system
+ }
+ }
+ if match == nil {
+ return nil, fmt.Errorf("%w: %s", errBMCSystemNotFound, wantID)
+ }
+ return match, nil
+ }
+ if len(present) == 1 {
+ return present[0], nil
+ }
+ return nil, errAmbiguousBMCSystems
+}
+
+// resetTypeForPower returns On when the host is off and ForceRestart only
+// when it is on. Any other PowerState is refused so a transitional state
+// is not force-restarted.
+func resetTypeForPower(state redfish.PowerState) (redfish.ResetType, error) {
+ switch state {
+ case redfish.OnPowerState:
+ return redfish.ForceRestartResetType, nil
+ case redfish.OffPowerState:
+ return redfish.OnResetType, nil
+ default:
+ return "", fmt.Errorf("refusing to change power while the host is in state %q", state)
+ }
+}
+
+type gofishSession struct {
+ client *gofish.APIClient
+}
+
+func (s *gofishSession) Logout() error {
+ if s == nil || s.client == nil {
+ return nil
+ }
+ s.client.Logout()
+ return nil
+}
+
+func (s *gofishSession) Systems() ([]bmcSystem, error) {
+ if s == nil || s.client == nil || s.client.Service == nil {
+ return nil, errNoBMCSystems
+ }
+ systems, err := s.client.Service.Systems()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]bmcSystem, 0, len(systems))
+ for _, system := range systems {
+ if system == nil {
+ continue
+ }
+ out = append(out, gofishSystem{system: system})
+ }
+ return out, nil
+}
+
+type gofishSystem struct {
+ system *redfish.ComputerSystem
+}
+
+func (s gofishSystem) ID() string {
+ if s.system == nil {
+ return ""
+ }
+ return s.system.ID
+}
+
+func (s gofishSystem) PowerState() redfish.PowerState {
+ if s.system == nil {
+ return ""
+ }
+ return s.system.PowerState
+}
+
+func (s gofishSystem) SetBoot(boot redfish.Boot) error {
+ return s.system.SetBoot(boot)
+}
+
+func (s gofishSystem) Reset(resetType redfish.ResetType) error {
+ return s.system.Reset(resetType)
+}
+
+// dialGofish connects with the caller's context, so a timeout on ctx bounds
+// the handshake and the calls made on the returned session.
+func dialGofish(ctx context.Context, endpoint, username, password string, verify bool) (bmcSession, error) {
+ client, err := gofish.ConnectContext(ctx, gofish.ClientConfig{
+ Endpoint: endpoint,
+ Username: username,
+ Password: password,
+ Insecure: !verify,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &gofishSession{client: client}, nil
+}
diff --git a/handlers/bmc_test.go b/handlers/bmc_test.go
new file mode 100644
index 0000000..a8977e4
--- /dev/null
+++ b/handlers/bmc_test.go
@@ -0,0 +1,202 @@
+package handlers
+
+import (
+ "context"
+ "crypto/x509"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "ignite/config"
+
+ "github.com/stmcginnis/gofish/redfish"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeBMCSystem struct {
+ id string
+ power redfish.PowerState
+ boot *redfish.Boot
+ reset redfish.ResetType
+ setErr error
+ rstErr error
+}
+
+func (s *fakeBMCSystem) ID() string { return s.id }
+func (s *fakeBMCSystem) PowerState() redfish.PowerState { return s.power }
+func (s *fakeBMCSystem) SetBoot(boot redfish.Boot) error {
+ if s.setErr != nil {
+ return s.setErr
+ }
+ s.boot = &boot
+ return nil
+}
+func (s *fakeBMCSystem) Reset(resetType redfish.ResetType) error {
+ s.reset = resetType
+ return s.rstErr
+}
+
+type fakeBMCSession struct {
+ systems []bmcSystem
+ closed bool
+}
+
+func (s *fakeBMCSession) Logout() error { s.closed = true; return nil }
+func (s *fakeBMCSession) Systems() ([]bmcSystem, error) { return s.systems, nil }
+
+func TestSelectBMCSystem(t *testing.T) {
+ one := &fakeBMCSystem{id: "1"}
+ two := &fakeBMCSystem{id: "2"}
+
+ got, err := selectBMCSystem([]bmcSystem{one}, "")
+ require.NoError(t, err)
+ require.Equal(t, "1", got.ID())
+
+ _, err = selectBMCSystem(nil, "")
+ require.ErrorIs(t, err, errNoBMCSystems)
+
+ _, err = selectBMCSystem([]bmcSystem{one, two}, "")
+ require.ErrorIs(t, err, errAmbiguousBMCSystems)
+
+ got, err = selectBMCSystem([]bmcSystem{one, two}, "2")
+ require.NoError(t, err)
+ require.Equal(t, "2", got.ID())
+
+ _, err = selectBMCSystem([]bmcSystem{one, two}, "9")
+ require.ErrorIs(t, err, errBMCSystemNotFound)
+}
+
+func TestResetTypeForPower(t *testing.T) {
+ kind, err := resetTypeForPower(redfish.OffPowerState)
+ require.NoError(t, err)
+ require.Equal(t, redfish.OnResetType, kind)
+
+ kind, err = resetTypeForPower(redfish.OnPowerState)
+ require.NoError(t, err)
+ require.Equal(t, redfish.ForceRestartResetType, kind)
+
+ _, err = resetTypeForPower(redfish.PoweringOffPowerState)
+ require.Error(t, err)
+}
+
+func TestSubmitIPMIUsesPowerStateAndSystemID(t *testing.T) {
+ off := &fakeBMCSystem{id: "sys-off", power: redfish.OffPowerState}
+ on := &fakeBMCSystem{id: "sys-on", power: redfish.OnPowerState}
+ session := &fakeBMCSession{systems: []bmcSystem{off, on}}
+ var gotVerify bool
+ var gotEndpoint string
+
+ h := &IPMIHandlers{
+ container: &Container{Config: &config.Config{BMC: config.BMCConfig{TLSVerify: true, SystemID: "sys-on"}}},
+ dial: func(ctx context.Context, endpoint, username, password string, verify bool) (bmcSession, error) {
+ require.NotNil(t, ctx)
+ deadline, ok := ctx.Deadline()
+ require.True(t, ok)
+ require.False(t, deadline.IsZero())
+ gotVerify = verify
+ gotEndpoint = endpoint
+ require.Equal(t, "root", username)
+ require.Equal(t, "secret", password)
+ return session, nil
+ },
+ }
+
+ form := url.Values{
+ "ip": {"10.0.0.5"},
+ "username": {"root"},
+ "password": {"secret"},
+ "reboot": {"on"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/pxe/submit_ipmi", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ h.SubmitIPMI(rec, req)
+
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ require.Equal(t, "/dhcp", rec.Header().Get("Location"))
+ require.True(t, gotVerify)
+ require.Equal(t, "https://10.0.0.5/redfish/v1", gotEndpoint)
+ require.Equal(t, redfish.ForceRestartResetType, on.reset)
+ require.Empty(t, off.reset)
+ require.True(t, session.closed)
+
+ off.reset = ""
+ on.reset = ""
+ form.Set("system_id", "sys-off")
+ req = httptest.NewRequest(http.MethodPost, "/pxe/submit_ipmi", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec = httptest.NewRecorder()
+ h.SubmitIPMI(rec, req)
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ require.Equal(t, redfish.OnResetType, off.reset)
+ require.Empty(t, on.reset)
+}
+
+func TestSubmitIPMIAmbiguousSystems(t *testing.T) {
+ session := &fakeBMCSession{systems: []bmcSystem{
+ &fakeBMCSystem{id: "a", power: redfish.OnPowerState},
+ &fakeBMCSystem{id: "b", power: redfish.OffPowerState},
+ }}
+ h := &IPMIHandlers{
+ container: &Container{Config: &config.Config{}},
+ dial: func(context.Context, string, string, string, bool) (bmcSession, error) {
+ return session, nil
+ },
+ }
+ form := url.Values{"ip": {"10.0.0.8"}, "username": {"root"}, "password": {"secret"}, "reboot": {"on"}}
+ req := httptest.NewRequest(http.MethodPost, "/pxe/submit_ipmi", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ rec := httptest.NewRecorder()
+ h.SubmitIPMI(rec, req)
+ require.Equal(t, http.StatusConflict, rec.Code)
+ var body ErrorResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ require.Contains(t, body.Error.UserMessage, "system_id")
+}
+
+func TestDialGofishSelfSignedCert(t *testing.T) {
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ t.Cleanup(srv.Close)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ t.Cleanup(cancel)
+
+ _, err := dialGofish(ctx, srv.URL, "root", "secret", false)
+ require.Error(t, err)
+ require.False(t, certUnknown(err), "default must accept the self-signed certificate: %v", err)
+
+ _, err = dialGofish(ctx, srv.URL, "root", "secret", true)
+ require.Error(t, err)
+ require.True(t, certUnknown(err), "BMC_TLS_VERIFY must reject the self-signed certificate: %v", err)
+}
+
+func certUnknown(err error) bool {
+ var unknown x509.UnknownAuthorityError
+ return errors.As(err, &unknown)
+}
+
+func TestSubmitIPMISkipsVerifyByDefault(t *testing.T) {
+ var gotVerify bool
+ h := &IPMIHandlers{
+ container: &Container{Config: &config.Config{}},
+ dial: func(_ context.Context, _, _, _ string, verify bool) (bmcSession, error) {
+ gotVerify = verify
+ return &fakeBMCSession{systems: []bmcSystem{&fakeBMCSystem{id: "1", power: redfish.OffPowerState}}}, nil
+ },
+ }
+ form := url.Values{"ip": {"10.1.1.1"}, "username": {"root"}, "password": {"secret"}}
+ req := httptest.NewRequest(http.MethodPost, "/pxe/submit_ipmi", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ h.SubmitIPMI(rec, req)
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ require.False(t, gotVerify)
+}
diff --git a/handlers/bootmenu.go b/handlers/bootmenu.go
index 103fb5d..dfc844a 100644
--- a/handlers/bootmenu.go
+++ b/handlers/bootmenu.go
@@ -3,7 +3,6 @@ package handlers
import (
"context"
"fmt"
- "html/template"
"ignite/dhcp"
"log"
"net"
@@ -11,6 +10,7 @@ import (
"os"
"path/filepath"
"strings"
+ "text/template"
)
// BootMenuHandlers handles boot menu-related requests
@@ -68,12 +68,13 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request
}
}
- // The MAC address becomes part of file names — reject anything that is
- // not a real MAC so it cannot smuggle path separators.
- if _, err := net.ParseMAC(formData["mac"]); err != nil {
+ // Addresses and the hostname are interpolated into shell and XML.
+ // Reject anything that is not an IPv4 address or a DNS name, and store
+ // the canonical MAC so the filename and the template agree.
+ if err := validateInstallerFields(formData); err != nil {
HandleError(w, r, NewValidationError(
- fmt.Sprintf("Invalid MAC address: %v", err),
- "The MAC address is invalid",
+ err.Error(),
+ "Check the hostname, MAC address, and IP addresses",
))
return
}
@@ -125,6 +126,18 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request
return
}
+ // The token is issued before the template runs. A missing token must
+ // fail the render: text/template would otherwise write "".
+ token, err := h.provisionToken(r.Context(), formData["mac"])
+ if err != nil || token == "" {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("provision token unavailable: %v", err),
+ "A provision token could not be issued for this host",
+ ))
+ return
+ }
+ formData["provision_token"] = token
+
// Generate boot data (use buildconfig for HTTP URL, configFile for filesystem path)
buildconfig := fmt.Sprintf("configs/%s/%s", formData["typeSelect"], macDashes)
pxedata := h.generateBootData(formData, buildconfig)
@@ -155,8 +168,10 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request
return
}
- // Write config template to disk
- if err := h.writeTemplateToDisk(configTempl, configFile, formData); err != nil {
+ // Write config template to disk. The file holds the provision token, so
+ // it is owner-read/write only. The PXE menu does not get the token and
+ // stays readable by TFTP.
+ if err := h.writeInstallerConfig(configTempl, configFile, formData); err != nil {
HandleError(w, r, NewInternalError(
fmt.Sprintf("Error writing config file: %v", err),
"Unable to write the config file",
@@ -311,27 +326,85 @@ func (h *BootMenuHandlers) osToInitrd(os, version string) string {
return fmt.Sprintf("%s/initrd.img", h.osToName(os))
}
+// installerTemplateFuncs escape values for the two contexts stock templates
+// interpolate into: POSIX shell and XML text.
+func installerTemplateFuncs() template.FuncMap {
+ return template.FuncMap{
+ "shellQuote": shellQuote,
+ "xmlEscape": xmlEscape,
+ }
+}
+
// writeTemplateToDisk parses the template file and writes the rendered
-// output to disk. Parse failures are returned as errors — never panics.
-func (h *BootMenuHandlers) writeTemplateToDisk(templpath string, filepath string, data interface{}) error {
- tmpl, err := template.ParseFiles(templpath)
+// output to disk. These files are installer and boot-loader text, so this
+// uses text/template. html/template treats an AutoYaST
+
diff --git a/public/provision/templates/cloud-init/default.templ b/public/provision/templates/cloud-init/default.templ
index f97c14b..1ba34b2 100755
--- a/public/provision/templates/cloud-init/default.templ
+++ b/public/provision/templates/cloud-init/default.templ
@@ -37,35 +37,41 @@ packages:
- curl
- nmap
-# Run commands to apply network changes and partition the disk
+# Run commands to apply network changes and partition the disk.
+# Callbacks are best-effort: a missing server or a failed curl must not stop the install.
runcmd:
- - netplan apply
- - parted /dev/sda --script mklabel gpt
- - parted /dev/sda --script mkpart primary ext4 500MiB 2GiB
- - parted /dev/sda --script mkpart primary ext4 10Gib 20GiB
- - parted /dev/sda --script mkpart primary ext4 20GiB 100%
-
-# Format partitions
- - mkfs.ext4 -L boot /dev/sda1
- - mkfs.ext4 -L var /dev/sda2
- - mkfs.ext4 -L root /dev/sda3
-
-# Mount partitions
- - mkdir -p /mnt/boot /mnt/var /mnt/root
- - mount /dev/sda1 /mnt/boot
- - mount /dev/sda2 /mnt/var
- - mount /dev/sda3 /mnt/root
-
-# Write fstab entries
- - echo "/dev/sda1 /boot ext4 defaults 0 1" >> /etc/fstab
- - echo "/dev/sda2 /var ext4 defaults 0 2" >> /etc/fstab
- - echo "/dev/sda3 / ext4 defaults 0 2" >> /etc/fstab
-
-# Ensure mounts are applied after reboot
- - mount -a
-
-# Signal to cloud-init that the network configuration is done
- - cloud-init status --wait
-
-# Optionally, reboot to apply all changes
- - [ shutdown, -r, now ]
\ No newline at end of file
+ - |
+ #!/bin/sh
+ ignite_state() {
+ curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=$1" -d "source=cloud-init" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true
+ }
+ ignite_heartbeat() {
+ curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
+ }
+ ignite_state configuring
+ ignite_heartbeat
+ fail=0
+ netplan apply || fail=1
+ parted /dev/sda --script mklabel gpt || fail=1
+ parted /dev/sda --script mkpart primary ext4 500MiB 2GiB || fail=1
+ parted /dev/sda --script mkpart primary ext4 10Gib 20GiB || fail=1
+ parted /dev/sda --script mkpart primary ext4 20GiB 100% || fail=1
+ mkfs.ext4 -L boot /dev/sda1 || fail=1
+ mkfs.ext4 -L var /dev/sda2 || fail=1
+ mkfs.ext4 -L root /dev/sda3 || fail=1
+ mkdir -p /mnt/boot /mnt/var /mnt/root || fail=1
+ mount /dev/sda1 /mnt/boot || fail=1
+ mount /dev/sda2 /mnt/var || fail=1
+ mount /dev/sda3 /mnt/root || fail=1
+ echo "/dev/sda1 /boot ext4 defaults 0 1" >> /etc/fstab || fail=1
+ echo "/dev/sda2 /var ext4 defaults 0 2" >> /etc/fstab || fail=1
+ echo "/dev/sda3 / ext4 defaults 0 2" >> /etc/fstab || fail=1
+ mount -a || fail=1
+ cloud-init status --wait || fail=1
+ if [ "$fail" -eq 0 ]; then
+ ignite_state complete
+ else
+ ignite_state failed
+ fi
+ ignite_heartbeat
+ shutdown -r now || true
\ No newline at end of file
diff --git a/public/provision/templates/kickstart/default.templ b/public/provision/templates/kickstart/default.templ
index 952db29..2547b02 100755
--- a/public/provision/templates/kickstart/default.templ
+++ b/public/provision/templates/kickstart/default.templ
@@ -41,37 +41,62 @@ curl
nmap
%end
+# Callbacks are best-effort. A missing curl or an unreachable server must not stop the install.
+%pre --log=/tmp/ks-pre.log
+curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=imaging" -d "source=kickstart" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true
+curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
+%end
+
# Post installation script
%post --log=/root/ks-post.log
+ignite_state() {
+ curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=$1" -d "source=kickstart" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true
+}
+ignite_heartbeat() {
+ curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
+}
+ignite_state configuring
+ignite_heartbeat
+fail=0
# Update system
-dnf update -y
-dnf upgrade -y
+dnf update -y || fail=1
+dnf upgrade -y || fail=1
# Install additional packages if not included in the package group
-dnf install -y apt-transport-https
+dnf install -y apt-transport-https || fail=1
# Remove unnecessary packages
-dnf remove -y cups samba
+dnf remove -y cups samba || fail=1
# Clean up
-dnf clean all
+dnf clean all || fail=1
# Enable and start services
-systemctl enable sshd
-systemctl start sshd
+systemctl enable sshd || fail=1
+systemctl start sshd || fail=1
-systemctl enable firewalld
-systemctl start firewalld
+systemctl enable firewalld || fail=1
+systemctl start firewalld || fail=1
# Set up basic firewall rules if needed
-firewall-cmd --permanent --add-service=ssh
-firewall-cmd --reload
-
-# Optionally, reboot the system
+firewall-cmd --permanent --add-service=ssh || fail=1
+firewall-cmd --reload || fail=1
+
+if [ "$fail" -eq 0 ]; then
+ ignite_state complete
+else
+ ignite_state failed
+fi
+ignite_heartbeat
echo "Installation complete, rebooting..."
reboot
%end
+%onerror --log=/tmp/ks-error.log
+curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=failed" -d "source=kickstart" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true
+curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
+%end
+
# Additional configuration (if any)
%addon com_redhat_kdump --enable --reserve-mb='auto'
%end
\ No newline at end of file
diff --git a/public/provision/templates/preseed/default.templ b/public/provision/templates/preseed/default.templ
index 1df498e..f7e5228 100644
--- a/public/provision/templates/preseed/default.templ
+++ b/public/provision/templates/preseed/default.templ
@@ -59,8 +59,23 @@ d-i grub-installer/bootdev string /dev/sda
# Finish installation
d-i finish-install/reboot_in_progress note
+# Callbacks are best-effort. The installer environment has wget, not curl.
+# late_command runs in the installed system, which has curl from pkgsel.
+d-i preseed/early_command string \
+ wget -q -O /dev/null -T 10 --header={{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} --post-data={{ shellQuote "state=imaging&source=preseed" }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true; \
+ wget -q -O /dev/null -T 10 --header={{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} --post-data={{ shellQuote "" }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
+
# Custom commands to run after installation
d-i preseed/late_command string \
- in-target systemctl enable ssh; \
- in-target ufw enable; \
- in-target echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /target/etc/sudoers.d/admin
\ No newline at end of file
+ in-target curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=configuring" -d "source=preseed" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true; \
+ in-target curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true; \
+ fail=0; \
+ in-target systemctl enable ssh || fail=1; \
+ in-target ufw enable || fail=1; \
+ in-target echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /target/etc/sudoers.d/admin || fail=1; \
+ if [ "$fail" -eq 0 ]; then \
+ in-target curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=complete" -d "source=preseed" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true; \
+ else \
+ in-target curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} -d "state=failed" -d "source=preseed" {{ shellQuote (printf "http://%s/dhcp/lease/%s/state" .tftpip .mac) }} || true; \
+ fi; \
+ in-target curl -fsS -m 10 -X POST -H {{ shellQuote (printf "Authorization: Bearer %s" .provision_token) }} {{ shellQuote (printf "http://%s/dhcp/lease/%s/heartbeat" .tftpip .mac) }} || true
\ No newline at end of file
diff --git a/routes/callbacks_test.go b/routes/callbacks_test.go
new file mode 100644
index 0000000..ed81d8c
--- /dev/null
+++ b/routes/callbacks_test.go
@@ -0,0 +1,143 @@
+package routes
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "ignite/dhcp"
+
+ "github.com/stretchr/testify/require"
+)
+
+// callbackLeases is the small slice of LeaseService the host routes touch.
+type callbackLeases struct {
+ lease *dhcp.Lease
+ heartbeat int
+ state string
+ source string
+}
+
+func (s *callbackLeases) AssignLease(ctx context.Context, serverID string, mac string, requestedIP net.IP) (*dhcp.Lease, error) {
+ return nil, nil
+}
+func (s *callbackLeases) ReleaseLease(ctx context.Context, mac string) error { return nil }
+func (s *callbackLeases) ReserveLease(ctx context.Context, serverID string, mac string, ip net.IP) error {
+ return nil
+}
+func (s *callbackLeases) UnreserveLease(ctx context.Context, mac string) error { return nil }
+func (s *callbackLeases) GetLeaseByMAC(ctx context.Context, mac string) (*dhcp.Lease, error) {
+ if s.lease != nil && s.lease.MAC == mac {
+ return s.lease, nil
+ }
+ return nil, nil
+}
+func (s *callbackLeases) GetLeasesByServer(ctx context.Context, serverID string) ([]*dhcp.Lease, error) {
+ return nil, nil
+}
+func (s *callbackLeases) CleanupExpiredLeases(ctx context.Context) error { return nil }
+func (s *callbackLeases) UpdateLease(ctx context.Context, lease *dhcp.Lease) error {
+ return nil
+}
+func (s *callbackLeases) UpdateLeaseState(ctx context.Context, mac string, newState string, source string) error {
+ s.state = newState
+ s.source = source
+ return nil
+}
+func (s *callbackLeases) RecordHeartbeat(ctx context.Context, mac string) error {
+ s.heartbeat++
+ return nil
+}
+func (s *callbackLeases) GetLeaseStateHistory(ctx context.Context, mac string) ([]dhcp.StateTransition, error) {
+ return nil, nil
+}
+func (s *callbackLeases) GetLeasesByState(ctx context.Context, state string) ([]*dhcp.Lease, error) {
+ return nil, nil
+}
+func (s *callbackLeases) MarkOfflineLeases(ctx context.Context, offlineThreshold time.Duration) error {
+ return nil
+}
+
+func TestHostCallbackRoutesBypassSessionLogin(t *testing.T) {
+ const (
+ mac = "aa:bb:cc:dd:ee:04"
+ token = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ )
+ leases := &callbackLeases{lease: &dhcp.Lease{MAC: mac, ProvisionToken: token}}
+ container := createTestContainer()
+ container.LeaseService = leases
+ router := SetupWithContainerAndStatic(container, createTestStaticHandler())
+
+ for _, name := range []string{"RecordHeartbeat", "HostUpdateLeaseState", "DashboardSummary", "GetUIMode", "SetUIMode"} {
+ require.NotNil(t, router.Get(name), "route %s is not registered", name)
+ }
+
+ noToken := httptest.NewRequest(http.MethodPost, "/dhcp/lease/"+mac+"/heartbeat", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, noToken)
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ require.NotEqual(t, "/login", rec.Header().Get("Location"))
+
+ withToken := httptest.NewRequest(http.MethodPost, "/dhcp/lease/"+mac+"/heartbeat", nil)
+ withToken.Header.Set("Authorization", "Bearer "+token)
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, withToken)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 1, leases.heartbeat)
+
+ admin := httptest.NewRequest(http.MethodPost, "/dhcp/lease/state?mac="+mac, nil)
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, admin)
+ require.Equal(t, http.StatusFound, rec.Code)
+ require.Equal(t, "/login", rec.Header().Get("Location"))
+}
+
+func TestPreferenceAndSummaryRoutesRequireSession(t *testing.T) {
+ router := SetupWithContainerAndStatic(createTestContainer(), createTestStaticHandler())
+
+ login := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(url.Values{
+ "username": {"admin"},
+ "password": {"admin"},
+ }.Encode()))
+ login.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ loginRec := httptest.NewRecorder()
+ router.ServeHTTP(loginRec, login)
+ require.Equal(t, http.StatusOK, loginRec.Code)
+ var session *http.Cookie
+ for _, c := range loginRec.Result().Cookies() {
+ if c.Name == "ignite_session" {
+ session = c
+ }
+ }
+ require.NotNil(t, session)
+
+ // /status is a bookmark for Overview. /status/content stays the fragment.
+ statusReq := httptest.NewRequest(http.MethodGet, "/status", nil)
+ statusReq.AddCookie(session)
+ statusRec := httptest.NewRecorder()
+ router.ServeHTTP(statusRec, statusReq)
+ require.Equal(t, http.StatusFound, statusRec.Code)
+ require.Equal(t, "/", statusRec.Header().Get("Location"))
+
+ require.NotNil(t, router.Get("StatusContent"))
+
+ for _, path := range []string{"/dashboard/summary", "/status/content"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusFound, rec.Code, path)
+ require.Equal(t, "/login", rec.Header().Get("Location"), path)
+ }
+
+ // /api/ routes answer unauthenticated callers with JSON 401, not a login redirect.
+ api := httptest.NewRequest(http.MethodGet, "/api/preferences/mode", nil)
+ apiRec := httptest.NewRecorder()
+ router.ServeHTTP(apiRec, api)
+ require.Equal(t, http.StatusUnauthorized, apiRec.Code)
+ require.Contains(t, apiRec.Body.String(), "Authentication required")
+}
diff --git a/routes/routes.go b/routes/routes.go
index 8014a22..719b99e 100755
--- a/routes/routes.go
+++ b/routes/routes.go
@@ -22,6 +22,8 @@ func SetupWithContainerAndStatic(container *handlers.Container, staticHandler *h
bootMenuHandlers := handlers.NewBootMenuHandlers(container)
ipmiHandlers := handlers.NewIPMIHandlers(container)
statusHandlers := handlers.NewStatusHandlers(container)
+ dashboardHandlers := handlers.NewDashboardHandlers(container)
+ preferenceHandlers := handlers.NewPreferenceHandlers(container.Prefs)
modalHandlers := handlers.NewModalHandlers(container)
indexHandlers := handlers.NewIndexHandlers(container)
osImageHandlers := handlers.NewOSImageHandlers(container)
@@ -44,6 +46,11 @@ func SetupWithContainerAndStatic(container *handlers.Container, staticHandler *h
setupSyslinuxRoutes(router, syslinuxHandlers)
setupIPXERoutes(router, ipxeHandlers)
setupStatusRoutes(router, statusHandlers)
+ setupDashboardRoutes(router, dashboardHandlers)
+ setupPreferenceRoutes(router, preferenceHandlers)
+
+ router.NotFoundHandler = http.HandlerFunc(handlers.NotFound)
+ router.MethodNotAllowedHandler = http.HandlerFunc(handlers.MethodNotAllowed)
return router
}
@@ -83,9 +90,14 @@ func setupDHCPRoutes(router *mux.Router, handlers *handlers.DHCPHandlers) {
router.HandleFunc("/dhcp/delete_lease", handlers.DeleteLease).Methods("POST").Name("DeleteLease")
router.HandleFunc("/dhcp/add_manual_lease", handlers.AddManualLease).Methods("POST").Name("AddManualLease")
- // State management API routes
+ // State management API routes. The admin UI uses /dhcp/lease/state and
+ // stays behind the session cookie. Host callbacks are the two routes
+ // below; AuthMiddleware lets them through and the handlers require the
+ // per-lease provision token.
router.HandleFunc("/dhcp/lease/state", handlers.UpdateLeaseState).Methods("POST").Name("UpdateLeaseState")
router.HandleFunc("/dhcp/lease/history", handlers.GetLeaseStateHistory).Methods("GET").Name("GetLeaseStateHistory")
+ router.HandleFunc("/dhcp/lease/{mac}/heartbeat", handlers.RecordHeartbeat).Methods("POST").Name("RecordHeartbeat")
+ router.HandleFunc("/dhcp/lease/{mac}/state", handlers.HostUpdateLeaseState).Methods("POST").Name("HostUpdateLeaseState")
}
// setupTFTPRoutes configures TFTP file management routes
@@ -127,6 +139,9 @@ func setupProvisionRoutes(router *mux.Router, handlers *handlers.ProvisionHandle
func setupBootMenuRoutes(router *mux.Router, handlers *handlers.BootMenuHandlers) {
// POST routes
router.HandleFunc("/pxe/submit_menu", handlers.SubmitBootMenu).Methods("POST").Name("SubmitBootMenu")
+ // Installer download. AuthMiddleware lets it through; the handler allows
+ // only the lease IP or the provision token.
+ router.HandleFunc("/configs/{configType}/{name}", handlers.ServeRenderedConfig).Methods("GET").Name("RenderedConfig")
}
// setupIPMIRoutes configures IPMI management routes
@@ -135,9 +150,21 @@ func setupIPMIRoutes(router *mux.Router, handlers *handlers.IPMIHandlers) {
router.HandleFunc("/pxe/submit_ipmi", handlers.SubmitIPMI).Methods("POST").Name("SubmitIPMI")
}
-// setupStatusRoutes configures status monitoring routes
+// setupDashboardRoutes configures the Overview summary fragment.
+func setupDashboardRoutes(router *mux.Router, handlers *handlers.DashboardHandlers) {
+ router.HandleFunc("/dashboard/summary", handlers.Summary).Methods("GET").Name("DashboardSummary")
+}
+
+// setupPreferenceRoutes configures the per-user UI mode endpoints.
+func setupPreferenceRoutes(router *mux.Router, handlers *handlers.PreferenceHandlers) {
+ router.HandleFunc("/api/preferences/mode", handlers.GetMode).Methods("GET").Name("GetUIMode")
+ router.HandleFunc("/api/preferences/mode", handlers.SetMode).Methods("POST").Name("SetUIMode")
+}
+
+// setupStatusRoutes configures status routes.
+// GET /status redirects to Overview. GET /status/content is the fragment
+// Overview polls.
func setupStatusRoutes(router *mux.Router, handlers *handlers.StatusHandlers) {
- // GET routes
router.HandleFunc("/status", handlers.HandleStatusPage).Methods("GET").Name("Status")
router.HandleFunc("/status/content", handlers.HandleStatusContent).Methods("GET").Name("StatusContent")
}
diff --git a/scripts/build-flame.mjs b/scripts/build-flame.mjs
new file mode 100644
index 0000000..d6f3536
--- /dev/null
+++ b/scripts/build-flame.mjs
@@ -0,0 +1,239 @@
+// Crop the original Ignite flame and write the small brand assets.
+//
+// Source: assets/brand/Ignite_small.png (the illustrated multi-tongue flame).
+// The faint circle and swirl lines are dropped. The flame and the solid
+// sparks stay. Nothing written here is the 1.3 MB original.
+//
+// node scripts/build-flame.mjs
+//
+// Requires sharp (scripts/package.json).
+
+import { mkdir, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const source = path.join(root, "assets/brand/Ignite_small.png");
+const outDir = path.join(root, "public/http/img");
+const MAX_BYTES = 30 * 1024;
+
+const NAV_H = 32;
+const NAV_2X = 64;
+
+async function loadMask() {
+ const { data, info } = await sharp(source).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
+ const w = info.width;
+ const h = info.height;
+ const seen = new Uint8Array(w * h);
+ const keep = [];
+ const at = (x, y) => y * w + x;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const p = at(x, y);
+ if (seen[p]) continue;
+ const a = data[p * 4 + 3];
+ const r = data[p * 4];
+ if (a < 100 || r < 100) {
+ seen[p] = 1;
+ continue;
+ }
+ let n = 0;
+ let sumA = 0;
+ let minX = x;
+ let maxX = x;
+ let minY = y;
+ let maxY = y;
+ const stack = [[x, y]];
+ seen[p] = 1;
+ while (stack.length) {
+ const [cx, cy] = stack.pop();
+ n++;
+ sumA += data[at(cx, cy) * 4 + 3];
+ if (cx < minX) minX = cx;
+ if (cx > maxX) maxX = cx;
+ if (cy < minY) minY = cy;
+ if (cy > maxY) maxY = cy;
+ for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ const q = at(nx, ny);
+ if (seen[q]) continue;
+ const qa = data[q * 4 + 3];
+ const qr = data[q * 4];
+ if (qa < 100 || qr < 100) {
+ seen[q] = 1;
+ continue;
+ }
+ seen[q] = 1;
+ stack.push([nx, ny]);
+ }
+ }
+ const avg = sumA / n;
+ const bw = maxX - minX + 1;
+ const bh = maxY - minY + 1;
+ const aspect = bw / bh;
+ // Solid sparks and tongues. Thin, faint swirls stay out.
+ const solid = avg >= 200 && n >= 40;
+ const spark = avg >= 210 && n >= 18 && aspect > 0.25 && aspect < 4;
+ if (solid || spark) keep.push({ minX, minY, maxX, maxY });
+ }
+ }
+ if (!keep.length) throw new Error("no flame pixels found");
+ let minX = w;
+ let minY = h;
+ let maxX = 0;
+ let maxY = 0;
+ for (const c of keep) {
+ minX = Math.min(minX, c.minX);
+ minY = Math.min(minY, c.minY);
+ maxX = Math.max(maxX, c.maxX);
+ maxY = Math.max(maxY, c.maxY);
+ }
+ const pad = 18;
+ minX = Math.max(0, minX - pad);
+ minY = Math.max(0, minY - pad);
+ maxX = Math.min(w - 1, maxX + pad);
+ maxY = Math.min(h - 1, maxY + pad);
+ return { left: minX, top: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
+}
+
+function squareContain(size) {
+ return async (cropped) => {
+ const meta = await cropped.metadata();
+ const scale = size / Math.max(meta.width, meta.height);
+ const w = Math.max(1, Math.round(meta.width * scale));
+ const h = Math.max(1, Math.round(meta.height * scale));
+ const resized = await cropped.clone().resize(w, h, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
+ return sharp({
+ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
+ }).composite([{ input: resized, gravity: "centre" }]);
+ };
+}
+
+const FLAME_PALETTE = [
+ [252, 38, 0], [254, 76, 0], [254, 94, 0], [255, 116, 0], [255, 142, 0], [255, 180, 1],
+ [255, 196, 40], [255, 220, 80], [255, 244, 194], [230, 40, 0], [200, 20, 0], [255, 90, 20],
+ [255, 150, 30], [180, 30, 0], [255, 210, 120],
+];
+
+function nearestFlame(r, g, b) {
+ let best = FLAME_PALETTE[0];
+ let bestD = Infinity;
+ for (const p of FLAME_PALETTE) {
+ const d = (r - p[0]) ** 2 + (g - p[1]) ** 2 + (b - p[2]) ** 2;
+ if (d < bestD) {
+ bestD = d;
+ best = p;
+ }
+ }
+ return best;
+}
+
+// The 512px canvas does not fit under 30KB with a full gradient. Snap it to
+// the same red-to-gold colors so the PNG stays a small indexed image.
+async function quantizeFlame(pipeline) {
+ const { data, info } = await pipeline.clone().ensureAlpha().raw().toBuffer({ resolveWithObject: true });
+ for (let i = 0; i < data.length; i += 4) {
+ const a = data[i + 3];
+ if (a < 32) {
+ data[i] = data[i + 1] = data[i + 2] = data[i + 3] = 0;
+ continue;
+ }
+ const [r, g, b] = nearestFlame(data[i], data[i + 1], data[i + 2]);
+ data[i] = r;
+ data[i + 1] = g;
+ data[i + 2] = b;
+ data[i + 3] = a < 120 ? 96 : a < 200 ? 180 : 255;
+ }
+ return sharp(data, { raw: info });
+}
+
+async function writePNG(pipeline, file, colors) {
+ let buf = await pipeline.clone().png({
+ compressionLevel: 9,
+ palette: true,
+ quality: 90,
+ effort: 10,
+ colors,
+ }).toBuffer();
+ if (buf.length >= MAX_BYTES) {
+ const reduced = await quantizeFlame(pipeline);
+ buf = await reduced.png({
+ compressionLevel: 9,
+ palette: true,
+ quality: 80,
+ effort: 10,
+ colors: 48,
+ }).toBuffer();
+ }
+ if (buf.length >= MAX_BYTES) {
+ throw new Error(`${file} is ${buf.length} bytes`);
+ }
+ await writeFile(file, buf);
+ return buf.length;
+}
+
+async function writeWebP(pipeline, file, quality, alphaQuality = 90) {
+ let buf = await pipeline.clone().webp({ quality, alphaQuality, effort: 6 }).toBuffer();
+ if (buf.length >= MAX_BYTES) {
+ const reduced = await quantizeFlame(pipeline);
+ buf = await reduced.webp({ quality: Math.min(quality, 60), alphaQuality: 70, effort: 6 }).toBuffer();
+ }
+ if (buf.length >= MAX_BYTES) {
+ throw new Error(`${file} is ${buf.length} bytes`);
+ }
+ await writeFile(file, buf);
+ return buf.length;
+}
+
+const crop = await loadMask();
+console.log("crop", crop);
+const cropped = sharp(source).extract(crop);
+await mkdir(outDir, { recursive: true });
+
+const nav1 = cropped.clone().resize({ height: NAV_H, kernel: "lanczos3" });
+const nav2 = cropped.clone().resize({ height: NAV_2X, kernel: "lanczos3" });
+const navMeta = await sharp(path.join(outDir, "flame-nav.png")).metadata();
+
+const jobs = [
+ ["flame-nav.png", () => writePNG(nav1, path.join(outDir, "flame-nav.png"), 48)],
+ ["flame-nav.webp", () => writeWebP(nav1, path.join(outDir, "flame-nav.webp"), 78)],
+ ["flame-nav@2x.png", () => writePNG(nav2, path.join(outDir, "flame-nav@2x.png"), 64)],
+ ["flame-nav@2x.webp", () => writeWebP(nav2, path.join(outDir, "flame-nav@2x.webp"), 78)],
+ ["favicon-32.png", async () => writePNG(await squareContain(32)(cropped.clone()), path.join(outDir, "favicon-32.png"), 48)],
+ ["apple-touch-icon.png", async () => writePNG(await squareContain(180)(cropped.clone()), path.join(outDir, "apple-touch-icon.png"), 64)],
+ ["flame-512.png", async () => writePNG(await squareContain(512)(cropped.clone()), path.join(outDir, "flame-512.png"), 96)],
+ ["flame-512.webp", async () => writeWebP(await squareContain(512)(cropped.clone()), path.join(outDir, "flame-512.webp"), 32, 20)],
+];
+
+for (const [name, run] of jobs) {
+ const bytes = await run();
+ const info = await stat(path.join(outDir, name));
+ console.log(`${name}\t${info.size}\t(${bytes})`);
+}
+
+console.log(`nav file ${navMeta.width}x${navMeta.height}`);
+
+// Review sheet: 28px and 64px on the navy nav color.
+const navy = { r: 11, g: 16, b: 38, alpha: 1 };
+async function onNavy(height) {
+ const mark = await cropped.clone().resize({ height, kernel: "lanczos3" }).png().toBuffer();
+ const meta = await sharp(mark).metadata();
+ return sharp({
+ create: { width: meta.width + 24, height: meta.height + 16, channels: 4, background: navy },
+ }).composite([{ input: mark, left: 12, top: 8 }]).png().toBuffer();
+}
+const a = await onNavy(28);
+const b = await onNavy(32);
+const c = await onNavy(64);
+const sheet = await sharp({
+ create: { width: 280, height: 160, channels: 4, background: navy },
+})
+ .composite([
+ { input: a, left: 16, top: 16 },
+ { input: b, left: 80, top: 16 },
+ { input: c, left: 150, top: 16 },
+ ])
+ .png()
+ .toFile("/tmp/flame-nav-preview.png");
+console.log("preview", sheet);
diff --git a/scripts/capture.sh b/scripts/capture.sh
new file mode 100755
index 0000000..69edad3
--- /dev/null
+++ b/scripts/capture.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# Regenerate the README screenshots from the binary this script just built.
+# Requires Google Chrome (or CHROME_PATH) and free TCP 8080 and UDP 69.
+# Page templates are html/template files read from ./templates when the
+# process starts; there is no separate template compiler. CSS and images are
+# embedded by go:embed, so the binary is rebuilt after Tailwind runs.
+# Usage, from the repository root: bash scripts/capture.sh
+set -euo pipefail
+
+root="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$root"
+
+# Print socket inodes listening on a TCP or UDP port.
+listening_inodes() {
+ python3 - "$1" "$2" <<'PY'
+import sys
+port, proto = int(sys.argv[1]), sys.argv[2]
+files = ["/proc/net/tcp", "/proc/net/tcp6"] if proto == "tcp" else ["/proc/net/udp", "/proc/net/udp6"]
+want = f"{port:04X}"
+found = []
+for path in files:
+ try:
+ lines = open(path).read().splitlines()[1:]
+ except FileNotFoundError:
+ continue
+ for line in lines:
+ parts = line.split()
+ local, state, inode = parts[1], parts[3], parts[9]
+ if local.rsplit(":", 1)[-1].upper() != want:
+ continue
+ if proto == "tcp" and state != "0A":
+ continue
+ found.append(inode)
+print(" ".join(found))
+PY
+}
+
+# Print pids that own those socket inodes.
+pids_for_inodes() {
+ local inode pid fd target
+ for inode in "$@"; do
+ [[ -n "$inode" ]] || continue
+ for fd in /proc/[0-9]*/fd/*; do
+ target="$(readlink "$fd" 2>/dev/null || true)"
+ if [[ "$target" == "socket:[$inode]" ]]; then
+ pid="${fd#/proc/}"
+ pid="${pid%%/*}"
+ echo "$pid"
+ fi
+ done
+ done
+}
+
+require_port_free() {
+ local proto="$1" port="$2"
+ local inodes pids
+ inodes="$(listening_inodes "$port" "$proto")"
+ if [[ -z "${inodes// }" ]]; then
+ return 0
+ fi
+ pids="$(pids_for_inodes $inodes | sort -u | tr '\n' ' ')"
+ echo "Refusing to capture: ${proto} ${port} is already in use (pid ${pids:-unknown})." >&2
+ echo "A leftover server would answer /login and the screenshots would be stale." >&2
+ exit 1
+}
+
+npm install --prefix ./scripts --silent
+if [[ ! -x public/node_modules/.bin/tailwindcss ]]; then
+ npm install --prefix ./public --silent
+fi
+./public/node_modules/.bin/tailwindcss -i ./public/http/css/includes.css -o ./public/http/css/tailwind.css --minify
+rm -f bin/ignite
+go build -o bin/ignite .
+
+require_port_free tcp 8080
+require_port_free udp 69
+
+dbdir="$(mktemp -d /tmp/ignite-screenshots.XXXXXX)"
+# The parent shell may still export a previous lab's TFTP_DIR, PROV_DIR, or
+# DHCP_ENABLED. Screenshots have to come from this repo's defaults.
+unset TFTP_DIR PROV_DIR DHCP_ENABLED SYSLINUX_CACHE_DIR HTTP_PORT HTTP_DIR
+export DB_PATH="$dbdir" DB_FILE="ignite.db"
+go run ./scripts/seed-demo.go
+
+./bin/ignite >"$dbdir/server.log" 2>&1 &
+pid=$!
+cleanup() {
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+ready=0
+for _ in $(seq 1 40); do
+ if curl -sf -o /dev/null "http://127.0.0.1:8080/login"; then
+ ready=1
+ break
+ fi
+ if ! kill -0 "$pid" 2>/dev/null; then
+ break
+ fi
+ sleep 0.3
+done
+if [[ "$ready" -ne 1 ]]; then
+ echo "Ignite did not start. Log:" >&2
+ cat "$dbdir/server.log" >&2
+ exit 1
+fi
+
+listener="$(pids_for_inodes $(listening_inodes 8080 tcp) | sort -u | tr '\n' ' ')"
+if [[ "$listener" != *"$pid"* ]]; then
+ echo "Port 8080 is served by pid [${listener}] instead of the binary just started ($pid)." >&2
+ exit 1
+fi
+
+curl -fsS "http://127.0.0.1:8080/public/http/css/tailwind.css" -o "$dbdir/served.css"
+curl -fsS "http://127.0.0.1:8080/public/http/img/flame-nav.png" -o "$dbdir/served-flame.png"
+cmp -s public/http/css/tailwind.css "$dbdir/served.css" || {
+ echo "The server is serving CSS that is not the Tailwind file just built." >&2
+ exit 1
+}
+cmp -s public/http/img/flame-nav.png "$dbdir/served-flame.png" || {
+ echo "The server is serving a flame-nav.png that is not the current asset." >&2
+ exit 1
+}
+login_html="$(curl -fsS http://127.0.0.1:8080/login)"
+if ! grep -q 'flame-nav@2x.png' <<<"$login_html"; then
+ echo "Login HTML does not reference the current flame asset." >&2
+ exit 1
+fi
+echo "Serving pid $pid from $(readlink -f bin/ignite); embedded CSS and flame match this tree."
+
+node ./scripts/screenshots.mjs
+rm -f public/http/img/status_page.png
+echo "screenshots written to public/http/img/"
diff --git a/scripts/highlight_test.mjs b/scripts/highlight_test.mjs
new file mode 100644
index 0000000..13949b2
--- /dev/null
+++ b/scripts/highlight_test.mjs
@@ -0,0 +1,116 @@
+import { readFileSync } from "node:fs";
+import { createContext, runInContext } from "node:vm";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const source = readFileSync(resolve(root, "public/http/js/highlight.js"), "utf8");
+if (/\binnerHTML\b|\bouterHTML\b|insertAdjacentHTML/.test(source)) {
+ throw new Error("highlighter writes HTML strings");
+}
+
+function makeDocument() {
+ function node(name) {
+ const el = {
+ nodeName: name,
+ className: "",
+ attrs: {},
+ children: [],
+ set textContent(value) { el._text = String(value); },
+ get textContent() {
+ if (el.children.length === 0) return el._text || "";
+ return el.children.map((child) => child.textContent || "").join("");
+ },
+ setAttribute(key, value) { el.attrs[key] = value; },
+ getAttribute(key) { return el.attrs[key]; },
+ appendChild(child) { el.children.push(child); return child; },
+ replaceChildren() { el.children.length = 0; },
+ querySelectorAll(sel) {
+ const want = sel.replace("pre.", "");
+ const out = [];
+ const walk = (n) => {
+ if (n.className && String(n.className).split(/\s+/).includes(want)) out.push(n);
+ (n.children || []).forEach(walk);
+ };
+ walk(el);
+ return out;
+ },
+ };
+ el._text = "";
+ return el;
+ }
+ return {
+ createElement: (name) => node(String(name).toUpperCase()),
+ createTextNode: (text) => ({ nodeName: "#text", textContent: String(text), children: [] }),
+ addEventListener() {},
+ querySelectorAll() { return []; },
+ };
+}
+
+const sandbox = { console, document: makeDocument() };
+sandbox.globalThis = sandbox;
+createContext(sandbox);
+runInContext(source, sandbox);
+
+function joined(tokens) {
+ return tokens.map((tok) => tok.text).join("");
+}
+function classes(tokens) {
+ return new Set(tokens.map((tok) => tok.cls).filter(Boolean));
+}
+
+const samples = {
+ yaml: "#cloud-config\nhostname: {{ .hostname }}\npackages:\n - nginx\n",
+ kickstart: "#version=RHEL8\ntext\nnetwork --bootproto=static --ip=10.0.0.8\n%packages\n",
+ preseed: "# locale\nd-i netcfg/get_hostname string {{ .hostname }}\n",
+ ipxe: "#!ipxe\necho Welcome {{ .hostname }}\nset net0/ip ${net0/ip}\n:start\n",
+ shell: "#!/bin/sh\necho \"$HOME\"\nif [ -n \"$USER\" ]; then exit 0; fi\n",
+ ini: "default linux\ntimeout 50\nLABEL linux\n KERNEL vmlinuz\n",
+ xml: "\n{{ .ip }}\n",
+};
+
+for (const [lang, sample] of Object.entries(samples)) {
+ const tokens = sandbox.igniteTokenize(sample, lang);
+ if (joined(tokens) !== sample) {
+ throw new Error(`${lang} dropped text\n${JSON.stringify(joined(tokens))}`);
+ }
+ const seen = classes(tokens);
+ if (seen.size < 2) throw new Error(`${lang} produced too few token classes: ${[...seen]}`);
+}
+
+const attack = "\n# not a tag\n";
+for (const lang of ["yaml", "kickstart", "preseed", "ipxe", "shell", "ini", "xml", "text"]) {
+ const pre = sandbox.document.createElement("pre");
+ sandbox.igniteHighlight(pre, attack, lang);
+ const scripts = pre.children.filter((child) => child.nodeName === "SCRIPT");
+ if (scripts.length) throw new Error(`${lang} created a script element`);
+ if (pre.textContent !== attack) throw new Error(`${lang} changed the text`);
+ if (pre.children.some((child) => child.nodeName !== "SPAN" && child.nodeName !== "#text")) {
+ throw new Error(`${lang} created an unexpected node`);
+ }
+}
+
+if (sandbox.igniteHighlightLanguage("templates/cloud-init/default.templ", "") !== "yaml") {
+ throw new Error("cloud-init path");
+}
+if (sandbox.igniteHighlightLanguage("boot.ipxe", "#!ipxe\n") !== "ipxe") {
+ throw new Error("ipxe name");
+}
+if (sandbox.igniteHighlightLanguage("notes.txt", "#!/bin/bash\n") !== "shell") {
+ throw new Error("shebang");
+}
+
+const yaml = sandbox.igniteTokenize("hostname: box\n", "yaml");
+if (!yaml.some((tok) => tok.cls === "tok-key" && tok.text === "hostname")) {
+ throw new Error("yaml key missing");
+}
+const ks = sandbox.igniteTokenize("network --hostname=box\n", "kickstart");
+if (!ks.some((tok) => tok.cls === "tok-keyword" && tok.text === "network")) {
+ throw new Error("kickstart keyword missing");
+}
+const pxe = sandbox.igniteTokenize("#!ipxe\necho hi\n", "ipxe");
+if (!pxe.some((tok) => tok.cls === "tok-keyword" && tok.text === "#!ipxe")) {
+ throw new Error("ipxe shebang missing");
+}
+
+console.log("highlight tests passed");
diff --git a/scripts/package-lock.json b/scripts/package-lock.json
new file mode 100644
index 0000000..285730f
--- /dev/null
+++ b/scripts/package-lock.json
@@ -0,0 +1,1433 @@
+{
+ "name": "scripts",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "puppeteer-core": "24.15.0",
+ "sharp": "0.34.3"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
+ "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz",
+ "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz",
+ "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz",
+ "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz",
+ "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz",
+ "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz",
+ "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz",
+ "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz",
+ "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz",
+ "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz",
+ "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz",
+ "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz",
+ "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz",
+ "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz",
+ "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz",
+ "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz",
+ "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz",
+ "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.0"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz",
+ "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.4.4"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz",
+ "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz",
+ "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz",
+ "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.10.6",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.6.tgz",
+ "integrity": "sha512-pHUn6ZRt39bP3698HFQlu2ZHCkS/lPcpv7fVQcGBSzNNygw171UXAKrCUhy+TEMw4lEttOKDgNpb04hwUAJeiQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.1",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.2",
+ "tar-fs": "^3.1.0",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/b4a": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
+ "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-events": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
+ "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "bare-abort-controller": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-fs": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz",
+ "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4",
+ "bare-url": "^2.2.2",
+ "fast-fifo": "^1.3.2"
+ },
+ "engines": {
+ "bare": ">=1.28.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
+ "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/bare-stream": {
+ "version": "2.13.4",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
+ "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.8.1",
+ "streamx": "^2.25.0",
+ "teex": "^1.0.1"
+ },
+ "peerDependencies": {
+ "bare-abort-controller": "*",
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ },
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-url": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz",
+ "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/basic-ftp": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-7.2.0.tgz",
+ "integrity": "sha512-gREyhyBstermK+0RbcJLbFhcQctg92AGgDe/h/taMJEOLRdtSswBAO9KmvltFSQWgM2LrwWu5SIuEUbdm3JsyQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1464554",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz",
+ "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.7.0"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-uri": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/netmask": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
+ "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "24.15.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.15.0.tgz",
+ "integrity": "sha512-2iy0iBeWbNyhgiCGd/wvGrDSo73emNFjSxYOcyAqYiagkYt5q4cPfVXaVDKBsukgc2fIIfLAalBZlaxldxdDYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.10.6",
+ "chromium-bidi": "7.2.0",
+ "debug": "^4.4.1",
+ "devtools-protocol": "0.0.1464554",
+ "typed-query-selector": "^2.12.0",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz",
+ "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.4",
+ "semver": "^7.7.2"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.3",
+ "@img/sharp-darwin-x64": "0.34.3",
+ "@img/sharp-libvips-darwin-arm64": "1.2.0",
+ "@img/sharp-libvips-darwin-x64": "1.2.0",
+ "@img/sharp-libvips-linux-arm": "1.2.0",
+ "@img/sharp-libvips-linux-arm64": "1.2.0",
+ "@img/sharp-libvips-linux-ppc64": "1.2.0",
+ "@img/sharp-libvips-linux-s390x": "1.2.0",
+ "@img/sharp-libvips-linux-x64": "1.2.0",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.0",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.0",
+ "@img/sharp-linux-arm": "0.34.3",
+ "@img/sharp-linux-arm64": "0.34.3",
+ "@img/sharp-linux-ppc64": "0.34.3",
+ "@img/sharp-linux-s390x": "0.34.3",
+ "@img/sharp-linux-x64": "0.34.3",
+ "@img/sharp-linuxmusl-arm64": "0.34.3",
+ "@img/sharp-linuxmusl-x64": "0.34.3",
+ "@img/sharp-wasm32": "0.34.3",
+ "@img/sharp-win32-arm64": "0.34.3",
+ "@img/sharp-win32-ia32": "0.34.3",
+ "@img/sharp-win32-x64": "0.34.3"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.10",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
+ "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/streamx": {
+ "version": "2.28.1",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
+ "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
+ "license": "MIT",
+ "dependencies": {
+ "events-universal": "^1.0.0",
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
+ "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz",
+ "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.9.0",
+ "bare-fs": "^4.8.2",
+ "fast-fifo": "^1.3.2",
+ "streamx": "^2.28.1"
+ }
+ },
+ "node_modules/teex": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
+ "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
+ "license": "MIT",
+ "dependencies": {
+ "streamx": "^2.12.5"
+ }
+ },
+ "node_modules/text-decoder": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
+ "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.3",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
+ "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/scripts/package.json b/scripts/package.json
new file mode 100644
index 0000000..24f0369
--- /dev/null
+++ b/scripts/package.json
@@ -0,0 +1,8 @@
+{
+ "private": true,
+ "description": "Headless capture for the README screenshots. See scripts/screenshots.mjs.",
+ "dependencies": {
+ "puppeteer-core": "24.15.0",
+ "sharp": "0.34.3"
+ }
+}
diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs
new file mode 100644
index 0000000..ae574a5
--- /dev/null
+++ b/scripts/screenshots.mjs
@@ -0,0 +1,185 @@
+// Capture the README screenshots from a running Ignite server.
+//
+// IGNITE_URL=http://127.0.0.1:8080 node scripts/screenshots.mjs
+//
+// Chrome is taken from CHROME_PATH, or google-chrome / chromium on PATH.
+// The server should already be logged-in-able as admin/admin and seeded
+// (scripts/seed-demo.go). Writes a Cozy and a Console PNG for each nav
+// page under public/http/img/, plus the login page and the DHCP host
+// detail. Each navigation reloads Cozy from the server, then the script
+// switches to Console before the second capture. Viewport width is 1280.
+
+import { spawnSync } from "node:child_process";
+import { mkdirSync, statSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import puppeteer from "puppeteer-core";
+import sharp from "sharp";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const outDir = resolve(root, "public/http/img");
+const base = process.env.IGNITE_URL || "http://127.0.0.1:8080";
+
+function findChrome() {
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
+ for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"]) {
+ const found = spawnSync("which", [name], { encoding: "utf8" });
+ if (found.status === 0 && found.stdout.trim()) return found.stdout.trim();
+ }
+ throw new Error("Chrome not found. Set CHROME_PATH.");
+}
+
+async function save(page, filename) {
+ await page.waitForFunction(() => {
+ const nodes = [...document.querySelectorAll("[data-time]")];
+ if (!nodes.length) return true;
+ return nodes.some((node) => /[A-Za-z]{3}\s+\d{1,2},/.test(node.textContent || ""));
+ }, { timeout: 8000 }).catch(() => {
+ throw new Error(`${filename}: timestamps were not rewritten with a short zone`);
+ });
+ const png = await page.screenshot({ type: "png" });
+ const file = resolve(outDir, filename);
+ await sharp(png).png({ compressionLevel: 9, effort: 10 }).toFile(file);
+ const meta = await sharp(file).metadata();
+ if (meta.width !== 1280) throw new Error(`${filename} is ${meta.width}px wide`);
+ const kb = Math.round(statSync(file).size / 1024);
+ console.log(`${filename} ${meta.width}x${meta.height} ${kb}KB`);
+}
+
+async function assertCurrentOverview(page) {
+ const report = await page.evaluate(() => {
+ const nav = document.querySelector(".br img");
+ const text = document.body.innerText;
+ return {
+ navSrc: nav ? nav.getAttribute("src") : "",
+ activeLeases: text.includes("Active leases"),
+ inFlight: text.includes("In flight"),
+ freeAddresses: text.includes("Free addresses"),
+ serverId: text.includes("Server ID"),
+ lastUpdated: text.includes("Last Updated"),
+ };
+ });
+ if (!report.navSrc.includes("flame-nav.png")) {
+ throw new Error(`overview nav is not the current flame (${report.navSrc || "missing"})`);
+ }
+ if (!report.activeLeases || !report.inFlight || !report.freeAddresses) {
+ throw new Error("overview is missing the six-cell strip");
+ }
+ if (report.serverId || report.lastUpdated) {
+ throw new Error("overview still has the old status copy");
+ }
+}
+
+async function settle(page) {
+ await page.waitForNetworkIdle({ idleTime: 400, timeout: 8000 }).catch(() => {});
+ await new Promise((r) => setTimeout(r, 250));
+}
+
+async function setMode(page, mode) {
+ const saved = page.waitForResponse(
+ (r) => r.url().includes("/api/preferences/mode") && r.request().method() === "POST",
+ { timeout: 8000 },
+ );
+ await page.evaluate((next) => igniteSetMode(next), mode);
+ const response = await saved;
+ if (!response.ok()) throw new Error(`saving display mode ${mode} returned ${response.status()}`);
+ await page.waitForFunction((next) => document.documentElement.getAttribute("data-theme") === next, {}, mode);
+ await new Promise((r) => setTimeout(r, 200));
+}
+
+async function openProvisionFile(page) {
+ await page.evaluate(() => {
+ const item = document.querySelector(".file-item");
+ if (item && typeof selectFile === "function") selectFile(item);
+ });
+ await page.waitForFunction(() => {
+ const editor = document.getElementById("editor-textarea");
+ return editor && editor.value.length > 0;
+ });
+}
+
+async function selectHost(page) {
+ const picked = await page.evaluate(() => {
+ const theme = document.documentElement.getAttribute("data-theme");
+ const sel = theme === "console" ? ".lease-rows .lease-pick" : ".cozy-view .lease-pick";
+ const el = document.querySelector(sel);
+ if (!el || typeof selectLease !== "function") return "";
+ selectLease(el);
+ const body = document.getElementById("lease-detail-body");
+ return body && !body.hidden ? el.getAttribute("data-ip") || "selected" : "";
+ });
+ if (!picked) throw new Error("host detail did not open");
+}
+
+const browser = await puppeteer.launch({
+ executablePath: findChrome(),
+ headless: true,
+ args: ["--no-sandbox", "--disable-dev-shm-usage", "--hide-scrollbars", "--force-device-scale-factor=1"],
+ defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
+});
+
+try {
+ mkdirSync(outDir, { recursive: true });
+ const page = await browser.newPage();
+ await page.setCacheEnabled(false);
+ page.setDefaultTimeout(20000);
+ await page.goto(`${base}/login`, { waitUntil: "networkidle0" });
+ await settle(page);
+ await save(page, "login_page.png");
+ await page.type("#username", "admin");
+ await page.type("#password", "admin");
+ await page.click('#login-form button[type="submit"]');
+ await page.waitForFunction(() => location.pathname === "/");
+ await settle(page);
+ await assertCurrentOverview(page);
+
+ await save(page, "main_page.png");
+ await setMode(page, "console");
+ await save(page, "console_page.png");
+
+ const pages = [
+ ["/dhcp", "dhcp_page.png", "dhcp_console.png"],
+ ["/tftp", "tftp_page.png", "tftp_console.png"],
+ ["/osimages", "osimage_page.png", "osimage_console.png"],
+ ["/syslinux", "syslinux_page.png", "syslinux_console.png"],
+ ["/provision", "provision_page.png", "provision_console.png"],
+ ];
+ for (const [path, cozyFile, consoleFile] of pages) {
+ // DHCP draws both servers, their lease tables, and the detail panel.
+ // A short viewport scrolls the second table under the status bar.
+ if (path === "/dhcp") {
+ await page.setViewport({ width: 1280, height: 1280, deviceScaleFactor: 1 });
+ }
+ await page.goto(base + path, { waitUntil: "networkidle0" });
+ await settle(page);
+ // Mode is stored on the server, so a navigation keeps the last choice.
+ await setMode(page, "cozy");
+ if (path === "/provision") await openProvisionFile(page);
+ await save(page, cozyFile);
+ await setMode(page, "console");
+ if (path === "/provision") {
+ const stillOpen = await page.$eval("#editor-textarea", (el) => el.value.length > 0).catch(() => false);
+ if (!stillOpen) await openProvisionFile(page);
+ }
+ await save(page, consoleFile);
+ if (path === "/dhcp") {
+ await setMode(page, "cozy");
+ await selectHost(page);
+ await save(page, "dhcp_detail.png");
+ await setMode(page, "console");
+ await selectHost(page);
+ await save(page, "dhcp_detail_console.png");
+ await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
+ }
+ }
+
+ const client = await page.createCDPSession();
+ await client.send("Network.clearBrowserCookies");
+ await page.goto(`${base}/login`, { waitUntil: "networkidle0" });
+ await settle(page);
+ const loginTheme = await page.$eval("html", (el) => el.getAttribute("data-theme"));
+ if (loginTheme !== "console") throw new Error(`console login rendered as ${loginTheme}`);
+ await save(page, "login_console.png");
+} finally {
+ await browser.close();
+}
diff --git a/scripts/seed-demo.go b/scripts/seed-demo.go
new file mode 100644
index 0000000..06e0c1c
--- /dev/null
+++ b/scripts/seed-demo.go
@@ -0,0 +1,168 @@
+// Seed a database with the sample DHCP, OS image, and Syslinux records the
+// README screenshots are taken from. The server must not have this database
+// open. Configuration comes from the usual environment variables (DB_PATH,
+// DB_FILE, TFTP_DIR, PROV_DIR).
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "time"
+
+ "ignite/app"
+ "ignite/cli"
+ "ignite/dhcp"
+ "ignite/osimage"
+ "ignite/syslinux"
+)
+
+func main() {
+ ctx := context.Background()
+ container, err := app.NewContainer()
+ if err != nil {
+ fatal(err)
+ }
+ defer container.Close()
+
+ mock := cli.NewMockDataService(container.ServerService, container.LeaseService)
+ if err := mock.ClearAllData(ctx); err != nil {
+ fatal(err)
+ }
+ if err := mock.PopulateMockData(ctx); err != nil {
+ fatal(err)
+ }
+ if err := decorateLeases(ctx, container.LeaseService, container.LeaseRepo); err != nil {
+ fatal(err)
+ }
+ if err := seedOSImages(ctx, container.OSImageRepo); err != nil {
+ fatal(err)
+ }
+ if err := seedSyslinux(ctx, container.SyslinuxRepo); err != nil {
+ fatal(err)
+ }
+ fmt.Println("seeded screenshot database")
+}
+
+func fatal(err error) {
+ panic(err)
+}
+
+func decorateLeases(ctx context.Context, leases dhcp.LeaseService, repo dhcp.LeaseRepository) error {
+ type paint struct {
+ reserved bool
+ state dhcp.LeaseState
+ seen time.Duration
+ menu dhcp.BootMenu
+ ipmi dhcp.IPMI
+ }
+ now := time.Now()
+ looks := map[string]paint{
+ "00:11:22:33:44:55": {
+ reserved: false,
+ state: dhcp.StatePXERequested,
+ seen: 3 * time.Minute,
+ menu: dhcp.BootMenu{
+ Filename: "ubuntu/24.04/vmlinuz", OS: "ubuntu", Version: "24.04",
+ TemplateType: "cloud-init", TemplateName: "default", Hostname: "workstation-01",
+ },
+ },
+ "AA:BB:CC:DD:EE:FF": {
+ reserved: true,
+ state: dhcp.StateImaging,
+ seen: 45 * time.Second,
+ menu: dhcp.BootMenu{
+ Filename: "ubuntu/22.04/vmlinuz", OS: "ubuntu", Version: "22.04",
+ TemplateType: "autoinstall", Hostname: "lab-02",
+ },
+ ipmi: dhcp.IPMI{Username: "root", IP: net.ParseIP("192.168.1.201"), PXEBoot: true},
+ },
+ "00:11:22:33:44:66": {
+ reserved: true,
+ state: dhcp.StateComplete,
+ seen: 2 * time.Hour,
+ menu: dhcp.BootMenu{
+ Filename: "centos/stream9/vmlinuz", OS: "centos", Version: "9",
+ TemplateType: "kickstart", Hostname: "db-server",
+ },
+ ipmi: dhcp.IPMI{Username: "ipmi-admin", IP: net.ParseIP("192.168.1.202")},
+ },
+ "11:22:33:44:55:66": {
+ reserved: false,
+ state: dhcp.StateAssigned,
+ seen: 12 * time.Minute,
+ },
+ "77:88:99:AA:BB:CC": {
+ reserved: true,
+ state: dhcp.StateOffline,
+ seen: 36 * time.Hour,
+ },
+ }
+ for mac, paint := range looks {
+ lease, err := leases.GetLeaseByMAC(ctx, mac)
+ if err != nil {
+ return fmt.Errorf("lease %s: %w", mac, err)
+ }
+ lease.Reserved = paint.reserved
+ lease.State = paint.state
+ lease.StateUpdatedAt = now.Add(-paint.seen)
+ lease.LastSeen = now.Add(-paint.seen)
+ lease.Menu = paint.menu
+ lease.IPMI = paint.ipmi
+ // UpdateLease keeps a newer LastSeen, so the painted heartbeat would
+ // be discarded and every row would read "just now". The repository
+ // write is the seed's stored row.
+ if err := repo.Save(ctx, lease); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func seedOSImages(ctx context.Context, repo osimage.OSImageRepository) error {
+ when := time.Now().Add(-72 * time.Hour)
+ images := []*osimage.OSImage{
+ {
+ ID: "ubuntu-2404", OS: "ubuntu", Version: "24.04", Architecture: "x86_64",
+ KernelPath: "ubuntu/24.04/vmlinuz", InitrdPath: "ubuntu/24.04/initrd",
+ KernelSize: 14_680_064, InitrdSize: 81_264_640, Active: true, CreatedAt: when,
+ },
+ {
+ ID: "debian-12", OS: "debian", Version: "12.6", Architecture: "x86_64",
+ KernelPath: "debian/12/vmlinuz", InitrdPath: "debian/12/initrd.gz",
+ KernelSize: 8_126_464, InitrdSize: 52_428_800, CreatedAt: when.Add(6 * time.Hour),
+ },
+ {
+ ID: "fedora-40", OS: "fedora", Version: "40", Architecture: "x86_64",
+ KernelPath: "fedora/40/vmlinuz", InitrdPath: "fedora/40/initrd.img",
+ KernelSize: 13_631_488, InitrdSize: 104_857_600, CreatedAt: when.Add(24 * time.Hour),
+ },
+ }
+ for _, image := range images {
+ if err := repo.Save(ctx, image); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func seedSyslinux(ctx context.Context, repo syslinux.Repository) error {
+ downloaded := time.Now().Add(-10 * 24 * time.Hour)
+ versions := []*syslinux.SyslinuxVersion{
+ {
+ ID: "syslinux-6.03", Version: "6.03", Size: 7_340_032,
+ Downloaded: true, Active: true, DownloadedAt: &downloaded, CreatedAt: downloaded,
+ },
+ {
+ ID: "syslinux-6.04", Version: "6.04-pre1", Size: 7_512_064,
+ Downloaded: true, DownloadedAt: &downloaded,
+ CreatedAt: time.Now().Add(-48 * time.Hour),
+ },
+ }
+ for _, version := range versions {
+ if err := repo.SaveVersion(ctx, version); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/syslinux/activate_test.go b/syslinux/activate_test.go
new file mode 100644
index 0000000..2fe854c
--- /dev/null
+++ b/syslinux/activate_test.go
@@ -0,0 +1,199 @@
+package syslinux
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "ignite/config"
+ "ignite/db"
+
+ "github.com/stretchr/testify/require"
+)
+
+func testSyslinuxService(t *testing.T) (*service, Repository) {
+ t.Helper()
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(t.TempDir()).
+ WithDBFile("syslinux.db").
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := db.NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+
+ repo, err := NewBoltRepository(database)
+ require.NoError(t, err)
+
+ root := t.TempDir()
+ svc := NewService(repo, SyslinuxConfig{
+ TFTPDir: root,
+ BiosDir: "boot-bios",
+ EfiDir: "boot-efi",
+ TempDir: filepath.Join(root, "tmp"),
+ }).(*service)
+ return svc, repo
+}
+
+func writeExtractedBootFiles(t *testing.T, cfg SyslinuxConfig, version, contents string) {
+ t.Helper()
+ bios := ExtractedBootDir(cfg.TempDir, version, "bios")
+ efi := ExtractedBootDir(cfg.TempDir, version, "efi")
+ require.NoError(t, os.MkdirAll(bios, 0o755))
+ require.NoError(t, os.MkdirAll(efi, 0o755))
+ for name := range GetRequiredBiosFiles() {
+ require.NoError(t, os.WriteFile(filepath.Join(bios, name), []byte(contents), 0o644))
+ }
+ for name := range GetRequiredEfiFiles() {
+ require.NoError(t, os.WriteFile(filepath.Join(efi, name), []byte(contents), 0o644))
+ }
+}
+
+func TestActivateVersionRequiresDownload(t *testing.T) {
+ svc, repo := testSyslinuxService(t)
+ ctx := context.Background()
+ require.NoError(t, repo.SaveVersion(ctx, &SyslinuxVersion{
+ ID: "6.03",
+ Version: "6.03",
+ Downloaded: false,
+ }))
+
+ err := svc.ActivateVersion(ctx, "6.03")
+ require.ErrorIs(t, err, ErrVersionNotDownloaded)
+
+ _, err = repo.GetActiveVersion(ctx)
+ require.Error(t, err)
+}
+
+func TestActivateVersionUnknown(t *testing.T) {
+ svc, _ := testSyslinuxService(t)
+ err := svc.ActivateVersion(context.Background(), "9.99")
+ require.ErrorIs(t, err, ErrVersionNotFound)
+}
+
+func TestActivateVersionMissingBootFiles(t *testing.T) {
+ svc, repo := testSyslinuxService(t)
+ ctx := context.Background()
+ require.NoError(t, repo.SaveVersion(ctx, &SyslinuxVersion{
+ ID: "6.03",
+ Version: "6.03",
+ Downloaded: true,
+ }))
+
+ live := filepath.Join(svc.config.TFTPDir, svc.config.BiosDir, "pxelinux.0")
+ require.NoError(t, os.MkdirAll(filepath.Dir(live), 0o755))
+ require.NoError(t, os.WriteFile(live, []byte("still-booting"), 0o644))
+
+ err := svc.ActivateVersion(ctx, "6.03")
+ require.Error(t, err)
+ require.False(t, errors.Is(err, ErrVersionNotDownloaded))
+ require.Contains(t, err.Error(), "missing stored")
+
+ body, err := os.ReadFile(live)
+ require.NoError(t, err)
+ require.Equal(t, "still-booting", string(body))
+
+ got, err := repo.GetVersionByNumber(ctx, "6.03")
+ require.NoError(t, err)
+ require.True(t, got.Downloaded)
+ require.False(t, got.Active)
+}
+
+func TestActivateVersionCopyFailureKeepsLiveFiles(t *testing.T) {
+ svc, repo := testSyslinuxService(t)
+ ctx := context.Background()
+ live := filepath.Join(svc.config.TFTPDir, svc.config.BiosDir, "pxelinux.0")
+ require.NoError(t, os.MkdirAll(filepath.Dir(live), 0o755))
+ require.NoError(t, os.WriteFile(live, []byte("still-booting"), 0o644))
+ writeExtractedBootFiles(t, svc.config, "6.04", "stored-6.04")
+ require.NoError(t, repo.SaveVersion(ctx, &SyslinuxVersion{
+ ID: "6.04",
+ Version: "6.04",
+ Downloaded: true,
+ }))
+
+ orig := stageBootFile
+ calls := 0
+ stageBootFile = func(src, dst string) error {
+ calls++
+ if calls == 2 {
+ return errors.New("injected copy failure")
+ }
+ return orig(src, dst)
+ }
+ t.Cleanup(func() { stageBootFile = orig })
+
+ err := svc.ActivateVersion(ctx, "6.04")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "injected copy failure")
+ require.GreaterOrEqual(t, calls, 2)
+
+ body, err := os.ReadFile(live)
+ require.NoError(t, err)
+ require.Equal(t, "still-booting", string(body))
+ got, err := repo.GetVersionByNumber(ctx, "6.04")
+ require.NoError(t, err)
+ require.False(t, got.Active)
+
+ entries, err := os.ReadDir(svc.config.TFTPDir)
+ require.NoError(t, err)
+ for _, entry := range entries {
+ require.NotContains(t, entry.Name(), ".activate-")
+ }
+}
+
+func TestActivateVersionKeepsOtherDownloads(t *testing.T) {
+ svc, repo := testSyslinuxService(t)
+ ctx := context.Background()
+ // The previous version's bytes are what TFTP is serving now.
+ oldBIOS := filepath.Join(svc.config.TFTPDir, svc.config.BiosDir)
+ require.NoError(t, os.MkdirAll(oldBIOS, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(oldBIOS, "pxelinux.0"), []byte("old-6.03"), 0o644))
+ writeExtractedBootFiles(t, svc.config, "6.03", "stored-6.03")
+ writeExtractedBootFiles(t, svc.config, "6.04", "stored-6.04")
+
+ require.NoError(t, repo.SaveVersion(ctx, &SyslinuxVersion{
+ ID: "6.03",
+ Version: "6.03",
+ Downloaded: true,
+ Active: true,
+ }))
+ require.NoError(t, repo.SaveVersion(ctx, &SyslinuxVersion{
+ ID: "6.04",
+ Version: "6.04",
+ Downloaded: true,
+ Active: false,
+ }))
+
+ require.NoError(t, svc.ActivateVersion(ctx, "6.04"))
+
+ active, err := repo.GetActiveVersion(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "6.04", active.Version)
+
+ previous, err := repo.GetVersionByNumber(ctx, "6.03")
+ require.NoError(t, err)
+ require.True(t, previous.Downloaded, "activating one version must not mark the other as not downloaded")
+ require.False(t, previous.Active)
+
+ current, err := repo.GetVersionByNumber(ctx, "6.04")
+ require.NoError(t, err)
+ require.True(t, current.Downloaded)
+ require.True(t, current.Active)
+
+ installed, err := os.ReadFile(filepath.Join(svc.config.TFTPDir, svc.config.BiosDir, "pxelinux.0"))
+ require.NoError(t, err)
+ require.Equal(t, "stored-6.04", string(installed))
+ efi, err := os.ReadFile(filepath.Join(svc.config.TFTPDir, svc.config.EfiDir, "syslinux.efi"))
+ require.NoError(t, err)
+ require.Equal(t, "stored-6.04", string(efi))
+
+ // Activating the version that is already active installs that version again.
+ require.NoError(t, svc.ActivateVersion(ctx, "6.04"))
+ installed, err = os.ReadFile(filepath.Join(svc.config.TFTPDir, svc.config.BiosDir, "pxelinux.0"))
+ require.NoError(t, err)
+ require.Equal(t, "stored-6.04", string(installed))
+}
diff --git a/syslinux/errors.go b/syslinux/errors.go
new file mode 100644
index 0000000..8e238b9
--- /dev/null
+++ b/syslinux/errors.go
@@ -0,0 +1,14 @@
+package syslinux
+
+import "errors"
+
+var (
+ // ErrVersionNotFound is returned when no stored syslinux version matches
+ // the requested number.
+ ErrVersionNotFound = errors.New("syslinux version not found")
+
+ // ErrVersionNotDownloaded is returned when activation is requested for a
+ // version that has not been downloaded. Callers should surface this so
+ // the UI can offer download again.
+ ErrVersionNotDownloaded = errors.New("syslinux version is not downloaded")
+)
diff --git a/syslinux/interfaces.go b/syslinux/interfaces.go
index 3a103bb..187e498 100644
--- a/syslinux/interfaces.go
+++ b/syslinux/interfaces.go
@@ -41,7 +41,10 @@ type Service interface {
GetDownloadStatus(ctx context.Context, id string) (*DownloadStatus, error)
CancelDownload(ctx context.Context, id string) error
- // Version activation/deactivation
+ // Version activation/deactivation.
+ // ActivateVersion switches to a version that is already downloaded. It
+ // does not download, and it does not delete other downloaded versions.
+ ActivateVersion(ctx context.Context, version string) error
DeactivateVersion(ctx context.Context, version string) error
// Boot file management
diff --git a/syslinux/models.go b/syslinux/models.go
index 2dba0d8..5c77c5c 100644
--- a/syslinux/models.go
+++ b/syslinux/models.go
@@ -10,18 +10,21 @@ import (
// SyslinuxVersion represents a Syslinux version available for download
type SyslinuxVersion struct {
- ID string `json:"id"`
- Version string `json:"version"` // 6.03, 6.04-pre1, etc.
- BootType string `json:"boot_type"` // bios, efi, ipxe
- DownloadURL string `json:"download_url"` // Full URL to tar.gz
- FileName string `json:"file_name"` // syslinux-6.03.tar.gz
- Size int64 `json:"size"` // Size in bytes
- Checksum string `json:"checksum"` // SHA256 hash if available
- Downloaded bool `json:"downloaded"` // Whether it's been downloaded
- Active bool `json:"active"` // Currently active version
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DownloadedAt *time.Time `json:"downloaded_at,omitempty"`
+ ID string `json:"id"`
+ Version string `json:"version"` // 6.03, 6.04-pre1, etc.
+ BootType string `json:"boot_type"` // bios, efi, ipxe
+ DownloadURL string `json:"download_url"` // Full URL to tar.gz
+ FileName string `json:"file_name"` // syslinux-6.03.tar.gz
+ Size int64 `json:"size"` // Size in bytes
+ Checksum string `json:"checksum"` // SHA256 hash if available
+ Downloaded bool `json:"downloaded"` // Whether it's been downloaded
+ Active bool `json:"active"` // Currently active version
+ // NeedsRedownload is set for the page when Downloaded is still true but
+ // the extract cache is not in the current data directory. It is not stored.
+ NeedsRedownload bool `json:"-"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DownloadedAt *time.Time `json:"downloaded_at,omitempty"`
}
// SyslinuxBootFile represents an individual boot file extracted from Syslinux
@@ -45,7 +48,7 @@ type SyslinuxConfig struct {
TFTPDir string `json:"tftp_dir"` // ./public/tftp
BiosDir string `json:"bios_dir"` // boot-bios
EfiDir string `json:"efi_dir"` // boot-efi
- TempDir string `json:"temp_dir"` // /tmp/syslinux-downloads
+ TempDir string `json:"temp_dir"` // ./syslinux, or /app/data/syslinux in the image
AutoExtract bool `json:"auto_extract"` // Automatically extract after download
KeepArchive bool `json:"keep_archive"` // Keep downloaded tar.gz files
VerifyChecksum bool `json:"verify_checksum"` // Verify downloads with checksums
@@ -71,7 +74,7 @@ func GetDefaultConfig() SyslinuxConfig {
TFTPDir: "./public/tftp",
BiosDir: "boot-bios",
EfiDir: "boot-efi",
- TempDir: "/tmp/syslinux-downloads",
+ TempDir: "./syslinux",
AutoExtract: true,
KeepArchive: false,
VerifyChecksum: true,
diff --git a/syslinux/service.go b/syslinux/service.go
index 8d98da2..ab2371c 100644
--- a/syslinux/service.go
+++ b/syslinux/service.go
@@ -416,21 +416,17 @@ func (s *service) downloadAndInstall(ctx context.Context, statusID string, sysVe
return
}
- // Step 4: only now clean up the old active version and swap to the new one
- if err := s.cleanupActiveVersion(bg); err != nil {
- s.failStatus(bg, statusID, "failed to cleanup previous version: "+err.Error())
- return
- }
-
+ // Step 4: mark the version downloaded, then share ActivateVersion with
+ // the standalone activate route. That removes the previous boot files
+ // and installs this version's extracted files.
now := time.Now()
sysVersion.Downloaded = true
- sysVersion.Active = true
sysVersion.DownloadedAt = &now
if err := s.repo.SaveVersion(bg, sysVersion); err != nil {
s.failStatus(bg, statusID, "failed to save version: "+err.Error())
return
}
- if err := s.repo.SetActiveVersion(bg, sysVersion.Version); err != nil {
+ if err := s.ActivateVersion(bg, sysVersion.Version); err != nil {
s.failStatus(bg, statusID, "failed to activate version: "+err.Error())
return
}
@@ -640,14 +636,20 @@ func (s *service) extractSingleFile(ctx context.Context, tr *tar.Reader, header
return fmt.Errorf("unknown boot type for file %s", header.Name)
}
- // Ensure target directory exists
+ // Ensure target directory exists. A second copy is kept under TempDir so
+ // a later ActivateVersion can replace the single TFTP boot set.
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
+ storeDir := ExtractedBootDir(s.config.TempDir, version, bootType)
+ if err := os.MkdirAll(storeDir, 0755); err != nil {
+ return fmt.Errorf("failed to create extracted boot directory: %w", err)
+ }
// Get filename from path
fileName := filepath.Base(header.Name)
targetPath := filepath.Join(targetDir, fileName)
+ storePath := filepath.Join(storeDir, fileName)
// Create target file
outFile, err := os.Create(targetPath)
@@ -655,9 +657,14 @@ func (s *service) extractSingleFile(ctx context.Context, tr *tar.Reader, header
return fmt.Errorf("failed to create target file: %w", err)
}
defer outFile.Close()
+ storeFile, err := os.Create(storePath)
+ if err != nil {
+ return fmt.Errorf("failed to create stored boot file: %w", err)
+ }
+ defer storeFile.Close()
- // Copy file data
- if _, err := io.Copy(outFile, tr); err != nil {
+ // Copy file data into the TFTP directory and the per-version store.
+ if _, err := io.Copy(io.MultiWriter(outFile, storeFile), tr); err != nil {
return fmt.Errorf("failed to copy file data: %w", err)
}
@@ -924,36 +931,198 @@ func (s *service) CheckDiskSpace(ctx context.Context) (*DiskSpaceInfo, error) {
return &DiskSpaceInfo{}, nil
}
-// cleanupActiveVersion removes any currently active version and cleans up files.
-// It is only called after the new version has been fully downloaded,
-// extracted, and verified.
-func (s *service) cleanupActiveVersion(ctx context.Context) error {
- versions, err := s.repo.ListVersions(ctx)
+// ExtractedBootDir is the per-version copy of boot files written at extract
+// time. ActivateVersion copies this directory into the TFTP boot directories
+// after removing the files that were already there.
+func ExtractedBootDir(tempDir, version, bootType string) string {
+ return filepath.Join(tempDir, "extracted", version, bootType)
+}
+
+// stageBootFile copies one extracted boot file into the stage directory.
+// Tests replace it to fail a copy after an earlier file has succeeded.
+var stageBootFile = copyFile
+
+// ActivateVersion makes an already-downloaded version the active one.
+// It checks the extract cache first, copies those files into a temporary
+// directory inside the TFTP root, and only then swaps them into the live
+// boot directories. A missing cache or a failed copy leaves the files that
+// are already booting in place. Other downloaded versions stay downloaded.
+// Download uses this same function after extract, instead of only calling
+// SetActiveVersion.
+func (s *service) ActivateVersion(ctx context.Context, version string) error {
+ version = strings.TrimSpace(version)
+ if version == "" {
+ return fmt.Errorf("%w: empty version", ErrVersionNotFound)
+ }
+
+ sysVersion, err := s.repo.GetVersionByNumber(ctx, version)
+ if err != nil || sysVersion == nil {
+ return fmt.Errorf("%w: %s", ErrVersionNotFound, version)
+ }
+ if !sysVersion.Downloaded {
+ return fmt.Errorf("%w: %s", ErrVersionNotDownloaded, version)
+ }
+ if err := s.extractCacheReady(version); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(s.config.TFTPDir, 0755); err != nil {
+ return fmt.Errorf("failed to create TFTP directory: %w", err)
+ }
+ stage, err := os.MkdirTemp(s.config.TFTPDir, ".activate-")
if err != nil {
- return fmt.Errorf("failed to list versions: %w", err)
+ return fmt.Errorf("failed to stage boot files: %w", err)
}
+ defer os.RemoveAll(stage)
- for _, version := range versions {
- if version.Downloaded || version.Active {
- // Remove boot files
- s.RemoveBootFiles(ctx, version.Version, "bios")
- s.RemoveBootFiles(ctx, version.Version, "efi")
-
- // Remove downloaded archive
- archivePath := filepath.Join(s.config.TempDir, fmt.Sprintf("syslinux-%s.tar.gz", version.Version))
- os.Remove(archivePath)
-
- // Mark as not downloaded and not active
- version.Downloaded = false
- version.Active = false
- version.DownloadedAt = nil
- s.repo.SaveVersion(ctx, version)
+ if err := s.stageExtractedBootFiles(version, stage); err != nil {
+ return err
+ }
+ rollback, err := s.swapStagedBootFiles(stage)
+ if err != nil {
+ return err
+ }
+ if err := s.InstallBootFiles(ctx, version, "bios"); err != nil {
+ rollback()
+ return err
+ }
+ if err := s.InstallBootFiles(ctx, version, "efi"); err != nil {
+ rollback()
+ return err
+ }
+ if err := s.repo.SetActiveVersion(ctx, version); err != nil {
+ rollback()
+ return fmt.Errorf("failed to activate version: %w", err)
+ }
+ return nil
+}
+
+type stagedBootDir struct {
+ dest string
+ backup string
+ had bool
+}
+
+// extractCacheReady reports a missing extracted boot file before anything
+// in the live TFTP directories is touched.
+func (s *service) extractCacheReady(version string) error {
+ sets := []struct {
+ name string
+ required map[string]string
+ }{
+ {"bios", GetRequiredBiosFiles()},
+ {"efi", GetRequiredEfiFiles()},
+ }
+ for _, set := range sets {
+ srcDir := ExtractedBootDir(s.config.TempDir, version, set.name)
+ var missing []string
+ for name := range set.required {
+ if _, err := os.Stat(filepath.Join(srcDir, name)); err != nil {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ sort.Strings(missing)
+ return fmt.Errorf("missing stored %s boot files for version %s: %s",
+ set.name, version, strings.Join(missing, ", "))
}
}
+ return nil
+}
+// stageExtractedBootFiles copies the extract cache into stage/bios and
+// stage/efi. The live boot directories are not modified.
+func (s *service) stageExtractedBootFiles(version, stage string) error {
+ sets := []struct {
+ name string
+ required map[string]string
+ }{
+ {"bios", GetRequiredBiosFiles()},
+ {"efi", GetRequiredEfiFiles()},
+ }
+ for _, set := range sets {
+ dstDir := filepath.Join(stage, set.name)
+ if err := os.MkdirAll(dstDir, 0755); err != nil {
+ return fmt.Errorf("failed to create staged %s directory: %w", set.name, err)
+ }
+ srcDir := ExtractedBootDir(s.config.TempDir, version, set.name)
+ names := make([]string, 0, len(set.required))
+ for name := range set.required {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ if err := stageBootFile(filepath.Join(srcDir, name), filepath.Join(dstDir, name)); err != nil {
+ return fmt.Errorf("failed to stage %s: %w", name, err)
+ }
+ }
+ }
return nil
}
+// swapStagedBootFiles moves the current boot directories aside and renames
+// the staged directories into place. The returned function puts the previous
+// directories back.
+func (s *service) swapStagedBootFiles(stage string) (func(), error) {
+ var done []stagedBootDir
+ rollback := func() {
+ for i := len(done) - 1; i >= 0; i-- {
+ item := done[i]
+ _ = os.RemoveAll(item.dest)
+ if item.had {
+ _ = os.Rename(item.backup, item.dest)
+ }
+ }
+ }
+ for _, boot := range []struct{ name, dir string }{
+ {"bios", s.config.BiosDir},
+ {"efi", s.config.EfiDir},
+ } {
+ dest := filepath.Join(s.config.TFTPDir, boot.dir)
+ backup := filepath.Join(stage, "previous-"+boot.name)
+ item := stagedBootDir{dest: dest, backup: backup}
+ if st, err := os.Stat(dest); err == nil {
+ if !st.IsDir() {
+ rollback()
+ return nil, fmt.Errorf("%s boot path is not a directory", boot.name)
+ }
+ if err := os.Rename(dest, backup); err != nil {
+ rollback()
+ return nil, fmt.Errorf("failed to move current %s boot files aside: %w", boot.name, err)
+ }
+ item.had = true
+ } else if !os.IsNotExist(err) {
+ rollback()
+ return nil, err
+ }
+ if err := os.Rename(filepath.Join(stage, boot.name), dest); err != nil {
+ if item.had {
+ _ = os.Rename(backup, dest)
+ }
+ rollback()
+ return nil, fmt.Errorf("failed to install staged %s boot files: %w", boot.name, err)
+ }
+ done = append(done, item)
+ }
+ return rollback, nil
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ return err
+ }
+ return out.Close()
+}
+
// DeactivateVersion deactivates and removes the currently active version
func (s *service) DeactivateVersion(ctx context.Context, version string) error {
// Get the version to deactivate
diff --git a/syslinux/syslinux_test.go b/syslinux/syslinux_test.go
index bcc2a64..5ae00f6 100644
--- a/syslinux/syslinux_test.go
+++ b/syslinux/syslinux_test.go
@@ -205,7 +205,7 @@ func TestGetDefaultConfig(t *testing.T) {
assert.Equal(t, "./public/tftp", config.TFTPDir)
assert.Equal(t, "boot-bios", config.BiosDir)
assert.Equal(t, "boot-efi", config.EfiDir)
- assert.Equal(t, "/tmp/syslinux-downloads", config.TempDir)
+ assert.Equal(t, "./syslinux", config.TempDir)
assert.True(t, config.AutoExtract)
assert.False(t, config.KeepArchive)
assert.True(t, config.VerifyChecksum)
diff --git a/tailwind.config.js b/tailwind.config.js
index 4254647..5e10a5c 100755
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,4 +1,35 @@
/** @type {import('tailwindcss').Config} */
+const igniteColors = {
+ "primary": "#ff7400",
+ "primary-content": "#1a0b06",
+ "secondary": "#ffb401",
+ "secondary-content": "#1a0b06",
+ "accent": "#e4c8ff",
+ "accent-content": "#121a3a",
+ "neutral": "#c3cbe6",
+ "neutral-content": "#121a3a",
+ "base-100": "#121a3a",
+ "base-200": "#0b1026",
+ "base-300": "#1a1f45",
+ "base-content": "#eef1ff",
+ "info": "#a9d2ff",
+ "info-content": "#121a3a",
+ "success": "#6ee7b7",
+ "success-content": "#1a0b06",
+ "warning": "#ffc94d",
+ "warning-content": "#1a0b06",
+ "error": "#ffa48a",
+ "error-content": "#1a0b06",
+};
+
+function igniteTheme(box, btn, badge) {
+ return Object.assign({}, igniteColors, {
+ "--rounded-box": box,
+ "--rounded-btn": btn,
+ "--rounded-badge": badge,
+ });
+}
+
module.exports = {
content: [
"./**/*.templ",
@@ -6,24 +37,17 @@ module.exports = {
"./**/*.go",
],
plugins: [
- require('./public/node_modules/daisyui'),
+ require("./public/node_modules/daisyui"),
+ function ({ addVariant }) {
+ addVariant("console", 'html[data-theme="console"] &');
+ },
],
daisyui: {
themes: [
{
- ignite: {
- "primary": "#f97316",
- "secondary": "#FBBF24",
- "accent": "#FF4500",
- "neutral": "#1a1a1a",
- "base-100": "#2a2a2a",
- "info": "#3ABFF8",
- "success": "#36D399",
- "warning": "#FBBD23",
- "error": "#F87272",
- },
+ cozy: igniteTheme("1.125rem", "9999px", "9999px"),
+ console: igniteTheme("0.625rem", "0.5rem", "0.375rem"),
},
- "sunset",
],
},
-}
\ No newline at end of file
+};
diff --git a/templates/base-login.templ b/templates/base-login.templ
index a879de2..0aa7161 100644
--- a/templates/base-login.templ
+++ b/templates/base-login.templ
@@ -1,21 +1,17 @@
-
+
- {{.Title}}
+ Ignite
+
+
+
-
+
-
{{ template "content" . }}
diff --git a/templates/base.templ b/templates/base.templ
index 7d5e9e6..414b20b 100755
--- a/templates/base.templ
+++ b/templates/base.templ
@@ -1,243 +1,473 @@
-
+
-
- {{.Title}}
-
+
+
+ {{with fieldString . "Title"}}{{.}}{{else}}Ignite{{end}}
+
+
+
+
-
-
-
+
+
-
-
-