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 + 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. -![Main Dashboard](./public/http/img/main_page.png) + + + + + +
Cozy
Overview in Cozy
Console
Overview in Console
-### **DHCP Management** -Complete DHCP server management with lease monitoring, reservations, and dynamic IP allocation. +### **Other pages** -![DHCP Management](./public/http/img/dhcp_page.png) - -### **TFTP File Management** -File browser and management interface for boot files, operating system images, and PXE configurations. - -![TFTP File Management](./public/http/img/tftp_page.png) - -### **Provision Templates** -Cloud-init and Kickstart template editor with syntax highlighting, template management, file deletion, and clear distinction between templates and configs. - -![Provision Templates](./public/http/img/provision_page.png) - -### **OS Images Management** -Download, manage, and deploy operating system images with progress tracking and version control. - -![OS Images Management](./public/http/img/osimage_page.png) - -### **Syslinux Configuration** -Advanced PXE boot menu configuration and SYSLINUX management interface. - -![Syslinux Configuration](./public/http/img/syslinux_page.png) - -### **System Status** -Real-time monitoring of server status, services, and system health indicators. - -![System Status](./public/http/img/status_page.png) +- 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}} + + + + - - - + + - -
-
-
- -
- {{ template "content" . }} -
- - - + + - - + + - + - \ No newline at end of file + diff --git a/templates/modals/bootmodal.templ b/templates/modals/bootmodal.templ index 388db41..d54cdbb 100755 --- a/templates/modals/bootmodal.templ +++ b/templates/modals/bootmodal.templ @@ -1,7 +1,8 @@