# Changelog All notable changes to DockPanel will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [2.59.0] - 2026-08-02 **The panel host is a machine too.** Four releases threaded background services so an unattended action names the host it acts on. This one fixes the half none of them looked at: which hosts the panel can *see*. DockPanel kept per-host telemetry in two stores written by mutually exclusive halves of the fleet, so every check built on one was structurally blind to the other. `servers.cpu_usage` and its siblings are written only by the check-in handler, which only an agent phoning home reaches — and phone-home starts only from `agent.env`, a file only `install-agent.sh` writes. **The panel's own box has no such file**, so its row held NULL in every one of those columns, on every install ever made. `metrics_history` was the mirror: written only against the local server's id, so no member ever had a row in it. ### Fixed — the panel host could not be alerted on - **The panel host could never raise a CPU, memory or disk alert.** Its readings were NULL and the alert engine guards each threshold on the value being present, so all three silently skipped, for ever. On a single-server install — the default and commonest shape — that is *every* resource alert the product offers. - **Automatic disk recovery had therefore never executed on a single-server install.** `auto_clean_disk` is triggered by a firing `disk` alert, and that host could not raise one. The feature was reachable only on a fleet, from a member. - The panel's own hardware line on the Servers page was suppressed for the same reason: it renders only when CPU cores or RAM are known, and neither was. ### Fixed — members had no history - **No memory-leak detection for any member.** The trend check reads `metrics_history` and a member had no rows in it. - **A member's 24-hour uptime sparkline was 144 empty buckets** — drawn as a host that had been down for a day while it was checking in every 60 seconds. It is derived purely from the presence of history rows. - **The Prometheus scrape returned one server on a fleet of any size**, though `FEATURES.md` advertises a gauge per server. The exporter is written fleet-wide; the table under it only ever held one host. - The fleet-overview endpoint's CPU, memory and disk columns were null for every member. ### Changed - `metrics_collector` takes the agent registry and reads **every online server through that server's own agent**, concurrently, so one slow host cannot make a 30-second tick fall behind. It is now the single writer of `metrics_history` for the whole fleet, and — for the local row only — of the scalar columns a member reports by phoning home. Members keep getting theirs from check-in. - `AgentRegistry::online_fleet` now reports `is_local`, so an iterating service can tell the panel's row from a member's without a second query. - The offline sweep now names its `is_local` exemption in SQL. This is a no-op today and deliberately so: the local row's `last_seen_at` is NULL, and `NULL < …` is NULL rather than true, so the sweep has never matched it. That accident is now a stated rule, because `status = 'online'` is the predicate of both the fleet iterator and the alert engine's own query. ### Upgrade impact - **Prometheus consumers on a fleet will start receiving one series per server where they received one in total.** Any dashboard or alerting rule doing a bare `sum` or reading a single value changes meaning without erroring. This is the advertised behaviour finally holding; it is still a shape change. - **The panel host becomes eligible for automatic disk cleaning for the first time**, if auto-healing is enabled. That path cleans logs and `/tmp` files older than seven days, and — only when separately opted in — reclaims dangling images and build cache. It never touches volumes, site files or the database directory. - Existing history rows are untouched; no migration. ### Testing - `unattended-host-scope-pin-e2e.sh` gains §H (72 → 82 assertions), **10 of them watched red against v2.58.0** before being trusted. Among them the arm the last four releases each needed and none had: a **class** arm over the spawn sites asserting that exactly one background service may still hold the legacy single-agent handle. A per-defect arm cannot see a sibling left behind. ## [2.58.0] - 2026-08-02 **A scan belongs to the machine that was scanned.** The multi-server migration is finished. v2.56.0 threaded one background service, v2.57.0 threaded eleven more plus two webhook routes, and this release closes the last three — the two scanners and the alert engine — together with the healer path that only worked because two bugs cancelled. ### The unit was not uniform `security_scanner` and `image_scanner` take a **machine** as their subject, not a row: they ask an agent what it found, so there was no per-row `server_id` to thread and they needed an iterate-the-fleet loop instead. Both primitives that required already existed with zero callers — `online_server_ids`, doc-commented "for background services that need to iterate", and an `Option`-taking resolver that silently answers a missing id with the local agent and is now documented as the trap it is. `AgentRegistry::online_fleet` is the one primitive both scanners, the alert engine and the healer now share, so the skip-don't-substitute rule cannot drift between call sites. ### Fixed — fleet correctness - **Members were never security-scanned at all.** Not mislabelled: never looked at. The weekly scan asked the panel's own agent and filed the result under no server, and the cadence gate was fleet-wide, so the first host scanned satisfied it for every other host. The gate is now per server and only a **completed** scan satisfies it — it counted rows of any status, so one failed scan bought a whole week of no security scanning. - **File-integrity monitoring detected the FIRST change to a watched file and was deaf to every change after it.** The upsert's `ON CONFLICT (server_id, file_path)` named a column the INSERT never supplied, and a NULL never conflict-matches, so `DO UPDATE` had never once executed: every scan appended a new baseline and the comparison read the oldest row. Measured on a live panel: 126 rows for 7 watched paths — 18 duplicates each, one per weekly scan since March. The watched set includes `/etc/shadow`, `/etc/sudoers` and `/etc/ssh/sshd_config`. - **Image scan results and SBOMs collided across hosts.** `image_scan_findings` had no server column and `image_sbom` was keyed on the bare image string, so two machines running the same image overwrote each other. The deploy gate read the same bare key, so a clean scan on one host could wave a vulnerable image onto another; the 30-row history trim evicted a quiet host's only result; and the sweeper's freshness check let one host's scan suppress every other host's rescan indefinitely. - **Auto-restart would have restarted the wrong host's services.** `alert_engine` labelled every agent-driven reading with the oldest `servers` row while `auto_healer::auto_restart_services` read `alert_state` with no server predicate and posted to the local agent. The two cancelled, so the restart landed correctly by accident; writing correct server ids ends the accident. Both are fixed in this release — separately, either one is a regression. - **Auto-restart of exited containers had never run once.** It read `state` and `id` from the agent's `/apps` payload, which carries `status` and `container_id`. A missing JSON field reads as an empty string, so both the state test and the emptiness guard failed silently and permanently. - **The service-restart cooldown had never engaged.** It counted `activity_logs` rows written with the nil uuid, which violates the user foreign key, so the insert always failed and the count was always 0 — neither the 10-minute gap nor the give-up-after-3 rule ever applied, and a crash-looping service was restarted every 120 seconds for ever with no audit trail. The record is now written against the server's owner and stamped with `server_id`, which also stops two hosts running a service of the same name from sharing one budget. - **A four-month-old alert tombstone suppressed an entire alert type.** A removed container never reappears in `/apps`, so neither the fire branch nor the recovered branch could ever run for it and its row stayed `firing` for ever — silently suppressing every future `container_down` alert for that name, with retention unable to help because both purges only delete resolved rows. The container health check now clears state for containers the host no longer reports. - **The compliance report paired one server's scan with another's live data.** It scoped the agent it queried and then took whichever scan finished most recently. - Security scan history, posture and detail views are server-scoped; a clean scan on one machine no longer resolves another machine's security alerts. ### Added - `activity_logs.server_id` is written for the first time. The column, its index and its foreign key have existed since the multi-server migration with no writer and no reader; an unattended action that acts on one machine and records a row indistinguishable from the same action on another is not an audit trail. - 31 new regression-pin arms (§G of the unattended-host-scope suite), covering the s300 backup family — which shipped with none — as well as this release. 30 of the 31 were watched **red** against v2.57.0 before landing. ### Migrations Two, both deduplicating before backfilling because the order matters: collapsing 18 duplicate baselines onto each of 7 keys before the backfill would violate `UNIQUE(server_id, file_path)` and abort. `server_id` is then `NOT NULL` on `security_scans`, `file_integrity_baselines` and `image_scan_findings`, so an insert that forgets to bind the host fails loudly instead of silently recreating the inert-upsert defect. `image_sbom`'s primary key becomes `(server_id, image)`. ⚠ **`routes/sboms.rs` had to change in the same commit as that key.** An `ON CONFLICT` arbiter naming no unique index does not degrade — Postgres raises `42P10` at execution time — so the migration alone would have turned every SBOM generation into a 500. ## [2.57.0] - 2026-08-02 **A schedule belongs to the machine that owns it.** v2.56.0 threaded exactly one background service — the disk healer — through the per-server agent registry. Eleven others, and two webhook routes nobody had counted, still queried the whole fleet and acted on whichever machine runs the panel. This release closes both paths that were destructive rather than merely wrong, and the chain that made v2.56.0's own fix insufficient. Driven on a two-box fleet on the published v2.56.0. A member's cron git deploy ran **entirely on the panel host** — cloned into the panel's own `/var/lib/dockpanel/git/api`, built there, bound the panel's port — while the member ran nothing at all. Six times, each logged as `Deploy success (scheduled)`. The sharp edge is the checkout path, `/var/lib/dockpanel/git/{name}`, which is keyed by **name alone** while `idx_git_deploys_name_server` makes a deploy name unique only **per server**. Two servers may legitimately own an `api`, so on the executing host they share one working directory: whichever cloned first owns `origin`, and the other fetches against it, hard-resets it, and builds the **wrong repository** into the other tenant's container name — then reports success. Both directions were observed on one box. Nothing catches it, because the post-deploy health check fetches the deployment's public domain, which still resolves to the untouched container on the machine that was never deployed to. **Fixed** - **Scheduled git deploys run on the server that owns them.** `trigger_deploy_task` resolved `AgentHandle::Local` three lines before it read the row carrying `server_id`; it now resolves that server and **refuses out loud** when it is unreachable, rather than falling back to the local host — the fallback was the defect. Both scheduler queries now select `server_id`. - **Both deploy webhooks too.** A webhook carries a secret, not a session, so it has no `ServerScope` to read a server from — which is why both reached for the local agent. The row is the authority: a push to a deployment or a site owned by a remote server no longer builds, replaces containers and rewrites vhosts on the panel host. - **Preview teardown names its host.** `git_previews` carries no server of its own, but the sweep already joins `git_deploys`, whose `server_id` is `NOT NULL`. An expired preview is torn down on the machine it actually runs on; an unreachable server keeps its row for the next sweep instead of having a same-named container destroyed elsewhere. - **A one-time schedule is no longer lost, or replayed for ever.** Reachability is checked *before* the schedule is cleared, so an unreachable member keeps the operator's only copy of the instruction. And the clear is now *checked*: a successful run leaves the row `running`, which the one-time query does not exclude, so a clear that silently failed redeployed production every 60 seconds. - **`metrics_collector` labels local readings with the local server.** It asked for the *oldest* server row under a comment claiming it asked for the local one. These readings come from the local agent, so mislabelling them writes the panel host's disk usage against a member's `server_id` — which `alert_engine` thresholds, and which v2.56.0's now-correctly-scoped healer would then act on, cleaning the member because the panel is full. Reachable whenever the local row is not the first ever created: `servers.user_id` is `ON DELETE CASCADE`, so deleting the founding admin drops the local server row and the next restart mints a newer one. - **`for_server` recognises the local server from the database.** `ensure_local_server` returns a nil id until an admin exists, and the local row has no `agent_url`, so resolving the local server could return `NotFound`. Threading the fleet onto `for_server` is what made that reachable — without this a **single-server** install would have had every threaded service refuse to act on its own box. **Still fleet-blind, and tracked:** `backup_scheduler`, `backup_policy_executor`, `drill_scheduler`, `backup_verifier`, `security_scanner`, `image_scanner`, `alert_engine`, `telemetry_collector`. None of them destroy data — they read or attribute against the wrong host. `telemetry_collector` is legitimately local-only. New pin `unattended-host-scope-pin-e2e.sh` §F — 15 arms, all 15 red against v2.56.0, no skips. ## [2.56.0] - 2026-08-01 **An unattended service must name the host it acts on.** DockPanel's multi-server migration reached every HTTP route and **not one background service**. `AppState` has carried both handles for releases — `agents: AgentRegistry` ("dispatches to local or remote agents by server_id") and `agent: AgentClient` ("Legacy single-agent accessor") — and all twelve background services were spawned with the legacy one. Each of them queries rows across the whole fleet and then acts on whichever machine the panel happens to run on. For the disk healer that is not a routing bug, it is destruction. `auto_clean_disk` read one firing `disk` row with no `server_id` predicate, then sent the fixes to the local agent. `alert_state` is keyed per server, so **any** member crossing its threshold made the panel host clean and prune **itself**, while the machine that was actually full was never touched. Driven on a two-box fleet, on the released v2.55.0: the member was filled to 93%, the panel host sat at 18% with its own `disk_usage_pct` never even measured, and forty seconds after auto-healing was switched on the panel host lost a tenant's container and its image — an app the panel itself had put to sleep. The panel reported *"Auto-heal: disk cleanup succeeded, disk alert state reset."* Three further defects, each of which made it worse: - **The hourly cooldown could never engage.** It counts `activity_logs` rows for `auto_heal.clean_logs`, and those inserts were written with `Uuid::nil()`, which violates `fk_activity_logs_user`. Every insert failed, the count was always zero, and `docker system prune -af --volumes` ran on the same healthy host **every 120 seconds** — measured at 19:16:13, 19:18:13, 19:20:13, 19:22:13. The operator also had no audit record of any of it. - **The recovery transition was consumed, not completed.** A raw `UPDATE` to `'ok'` skipped `notifications::resolve_alert`, so the `alerts` row stayed `firing` for ever; the alert engine, seeing state already `'ok'`, never took its recovery branch again. Retention only purges `status = 'resolved'`, so those rows were also unpurgeable. - **The prune was indiscriminate.** `docker system prune -af --volumes` removes every stopped container — and a sleeping app *is* a stopped container — then `-a` takes every image no longer held by a running one, including locally built images with no registry to restore them from. `wake` only issues `docker start` on an id that no longer exists. Precisely, because the blast radius is often overstated: on Docker 23.0+ `--volumes` reclaims **anonymous** volumes only, and template apps keep their data in host bind mounts, so an app's *data* usually survives. What is destroyed is the container, its image, and any anonymous volume the image declared — which for a tenant database container is where the data lives. On the live fleet the panel host went from 2 containers and 2 images to 1 and 1 while the volume count was unchanged, which is exactly this shape. ### Fixed - **The disk heal resolves the agent for the server whose alert is firing**, via `AgentRegistry::for_server` — the primitive every HTTP route already uses. When that agent is unreachable it **refuses and says so**, rather than falling back to the local host, which was the whole defect. - **The cooldown is real and per-server**, its activity row written against the server's owner. It gates the next run and gives the operator the audit trail a destructive action owes them. - **Recovery goes through `resolve_alert`, scoped to that server**, so the `alerts` row resolves, the operator is told the server recovered, and retention can reach the row. - **`docker system prune -af --volumes` is gone.** A new `docker-reclaim` frees dangling images, build cache and unattached networks that DockPanel does not manage. **Volumes are deliberately never reclaimed** — an unattached volume is indistinguishable from one whose container the panel stopped on purpose. The old `docker-prune` id now routes to the scoped reclaim, so an older panel pointed at a newer agent cannot get the destructive behaviour back by name. - **Reclamation is opt-in and separately consented** (`auto_heal_docker_reclaim`, default off), with its own control in Settings. The Auto-Healing panel's text was wrong on every count: it named 90% where the default is 85, said "cleans logs" where the code also pruned Docker host-wide, and promised "All actions are logged in the Audit Log" — the log write that always failed. - **Backup retention no longer destroys the only record of an archive it did not delete.** The policy sweep unlinked `/var/backups/dockpanel/databases/{filename}` while the writer creates `.../databases/{db_name}/{filename}` (and the same for volumes, per container) — a path that can never exist, whose `ENOENT` was discarded, with the row deleted regardless. Every database and volume dump was orphaned on disk with nothing left pointing at it. The path now matches the writer, the row survives a failed unlink, and a backup belonging to another server is refused rather than silently forgotten. - **Policy retention is per resource.** `OFFSET n` over a policy's whole history kept `n` backups in total across every database it covered, so a policy protecting five databases kept five backups and four databases kept none. ## [2.55.0] - 2026-08-01 **A container name is not a key anybody owns.** `dockpanel-git-{X}` is the container of a git deployment called `X`. It was also the container of a *preview* of config `C` on branch `B` whenever `{C}-pr-{slug(B)}` spelled `X` — and `X` is a name the panel will happily create, because `is_valid_name` accepts hyphens. The agent resolved that name with `list_containers` and acted on whatever answered. What it did with it was blue-green: it read the domain off the container it had just found, swapped **that** domain's vhost to the pushed build, force-removed the container behind it, and reported a successful zero-downtime deploy. The ownership guard added in v2.53.0 authorised the vhost write, correctly — the victim's container really was the one behind the victim's vhost. The branch half of the colliding name is chosen by whoever can push to the repo, and `POST /api/webhooks/git/{id}/{secret}` has no auth extractor, so no panel account was needed to fire it. The same shared name also meant one checkout directory, one image repository (so one deploy's `prune` evicted the other's rollback history) and one unattended TTL sweep. `services::ownership` had five primitives and every one of them read a file. The largest thing the agent destroys had none. ### Fixed - **Previews have their own name space.** A preview is scoped `pr.`, and `.` is a character `is_valid_name` rejects, so no deployment can be named into it. The scope travels on the wire and is applied to the container name, the image repository and the on-disk checkout alike. - **`services::ownership` gained a container primitive.** Every git container records which space it was created in; a deploy that finds a container belonging to something else now refuses and changes nothing. The compatibility path that reaches the old shared space requires the caller's own recorded port to match the container's — every container predating this release is unlabelled, including the victim's, so an absent label is not evidence of anything there. - **The blue-green stand-in no longer occupies a real name.** `{name}-blue` is a name `is_valid_name` accepts, so updating the app `api` force-removed the running container of the app `api-blue`. The separator is now `.`, and the leftover-clearing step refuses anything not managed by DockPanel. Both twins. - **A blue-green swap frees the promoted name before it destroys anything, and reports when it cannot.** The commit phase removed the old container and renamed the new one with both results discarded — so a failed removal made the rename impossible, and the function returned success over a host whose nginx pointed at a container the *next* deploy would clear away as a leftover. - **Editing or adding a git deploy's domain writes its vhost.** `setup_nginx_proxy` had one call site, in the first-deploy branch, so a domain change moved the label and nothing else — the new hostname never got a server block, on that deploy or any later one. - **A masked environment value sent back is treated as unchanged.** The env editor is seeded from the masked read and posts every field back, and the container is the only place a Docker app's environment is stored — so saving any change wrote the literal mask over every secret and then removed the container holding the originals. The mask predicate also matched by unanchored substring, so `KEYCLOAK_ADMIN`, `NEXTAUTH_URL` and `AUTHENTIK_POSTGRESQL__HOST` were masked and destroyed alongside real secrets; the catalogue's own `secret: false` now exempts them. - **The v2.53.0 ownership guard reaches the paths it missed.** Renaming a site moved a shared wildcard certificate directory out from under every sibling vhost, and migrated a Fail2Ban jail at the non-injective `nginx-{domain}` name without proving either end. Disabling, enabling or saving the `.env` of a site ran `systemctl stop`/`restart` on a unit name that collapses `.` to `-`, so a tenant could stop a neighbour's app process. - **The unattended preview sweep keeps the only record of what it could not remove**, honours `preview_ttl_hours = 0` in both of its queries rather than one, and carries the row's own domain and port so a crashed preview's vhost and certificate are still released. Deleting a git deployment now tears down its previews before the foreign-key cascade forgets they exist. ### Added - `tests/container-identity-pin-e2e.sh` — 38 assertions, 37 of them red against v2.54.0. ## [2.54.0] - 2026-08-01 **A stack is a network, a namespace, and an honest status.** DockPanel's Compose support does not run `docker compose` — it creates each service directly through the Docker API, which is what lets the panel apply its own sandboxing to a pasted compose file. What that hand-rolled path has to reproduce deliberately is everything `docker compose` would have done for free, and until now it reproduced almost none of it. Services were created with **no user-defined network**. Every one landed on Docker's default bridge, where container-name DNS does not exist — so `postgresql://app:pw@database:5432` could not resolve, and **every multi-service package the panel ships died on boot**: Domain Watchdog, WordPress, Ghost and Nextcloud alike. The failure looked like a broken image rather than a broken deployer, because the deploy reported every service `running` on the strength of `create` and `start` returning `Ok`, and because our only end-to-end test for stacks deployed a single nginx and asserted that a UUID came back. A one-service stack cannot fail this way. Reported by @insxa on #50 — the same thread the Domain Watchdog package was shipped for in v2.52.0 — as "always crashing apps, ssl doesnt work properly". Both halves were accurate, and neither was about his box. ### Fixed — Compose stacks (#50) - **A stack gets its own bridge network**, and every service is attached to it under its compose service key as an alias — the name its siblings were actually configured with. A `container_name` chosen by the compose author becomes an additional alias rather than the host-level Docker name. - **Names are scoped per stack.** Container names, the network and named volumes all carry the stack's scope, so the `db` service of two packages can coexist; before, WordPress, Ghost and Nextcloud all wanted `dockpanel-compose-db` and the second package to be deployed failed on a name conflict. An existing volume is reused rather than renamed, so an upgrade never orphans data. - **`command:`, `depends_on:`, `labels:` and long-form `ports:` are no longer discarded.** None of the four was declared on the parser's service struct, so serde dropped them silently — which turned Domain Watchdog's `messenger:consume` worker into a second copy of its web server, and made `docker compose config` output deploy with no published port at all. Author labels now reach Docker; `dockpanel.*` keys stay the panel's. - **Services deploy in dependency order**, and over a deterministic iteration order — a `HashMap` had been randomising it per process. - **A service is reported running because it is.** The deploy waits for the container to still be up and returns its log tail when it is not, and the panel refuses to save a stack where nothing came up. ### Added — a stack can be given a domain and a certificate (#50) There was no vhost, domain or certificate path for a stack anywhere in the tree: the deploy request carried no domain field and the stack table had no column for one, so a deployed stack was reachable only on `127.0.0.1:{port}` from the server itself. Stacks now take an optional **Domain** and **SSL Email**, served through the same nginx and ACME path Docker apps use. The domain is claimed through `services::domain_claim`, and a stack is now visible to that check as a holder — a path that writes a vhost while being invisible to the claim system is how one domain ends up with two owners. Removal takes the vhost and certificates down through the v2.53.0 ownership guard, so a stack cannot delete a domain a site has since taken over. ### Fixed — the dashboard advertised an update that was not one (#98) Reported by @brunoDruon: after `update.sh` the banner still read *"DockPanel v2.49.0 is available (current: v2.52.0)"*. Two independent defects, and the second one fires on **every install**: - The render decision was not a version test of any kind. `update_available` was set because the stored string was non-empty, next to the current version it never compared against. - Nothing cleared the stored value unless GitHub's latest was byte-equal to the running version. `update.sh` never touches the settings table and the first poll after a restart is six hours out — so **every operator who upgraded saw the version they had just installed advertised back at them for six hours.** The comparison is now one shared semver answer used by the poller, the boot reconcile, both API surfaces and the apply guard; the stored advertisement is reconciled against the running binary at startup, off the network; and applying a target no newer than the installed version is refused, pointing at `/api/update/rollback` instead. The old comparator dropped a non-numeric segment and shifted the rest, so `2.53.0-rc.1` outranked its own GA. ### Fixed — other - **A failed stack edit no longer replaces the only copy of its YAML.** `docker_stacks` has no history table, and the write landed after the redeploy and unconditionally — while the agent reports per-service failure inside a 200. The previous definition is now held until the new one is known to run, and redeployed when it is not. - **Stopped containers are listed on the Logs page.** `docker ps` without `-a` excluded exactly the containers anybody opens that page for, and stack services had no Logs control at all — which is why bug reports about stacks arrive with no detail in them. - **`POST /api/stacks` runs the container-escape validator.** It guarded the agent endpoint the UI does not post to. - **The package cards work on a plain-HTTP panel.** They minted passwords with `crypto.randomUUID`, which is secure-context-only, so on the installer's cert-failed branch the card silently did nothing. - **Domain Watchdog ships its own `APP_SECRET`.** Upstream bakes a published one into the image, so every install signed with the same value. ### Testing - New `tests/compose-stack-pin-e2e.sh` — 43 arms, 42 red against v2.53.0. Attacked with 17 evasions, of which three beat the first draft: an alias field present but empty, a `confirm_running` whose result was discarded, and an arm that matched a label name inside the log line narrating the check rather than the check itself. - `tests/deep-e2e.sh` deploys a **two-service** stack and asserts one service resolves the other by compose service key. - 8 new Rust unit tests over the update comparison, 12 over compose parsing. ## [2.53.0] - 2026-07-31 v2.52.0 answered *may this domain be claimed?* This answers the question after it: **is the thing I am about to destroy actually mine?** Nothing owned that question, so every removal in the panel named its target by a key it had never checked — and the key lied in two different ways. ### Fixed — removing a Docker app could destroy a site's configuration and certificates An app's domain lives in the `dockpanel.app.domain` container label. Removing the app deleted, on the strength of that label alone: the nginx vhost, the certificate directory, both access logs, the Traefik route, and the data tree named by its `dockpanel.app.name` label. None of it checked that the app still owned any of those things. On a box installed before v2.52.0 nothing stopped a site from claiming a domain an app already held, so by removal time those files routinely belonged to the site. Every one of these deletes now reads the resource and confirms it names this container: the vhost must `proxy_pass` to the port the container published, and the data directory must be one the container actually bind-mounts. What cannot be proved is left in place and logged, loudly — a stale file is an untidy box, a deleted one is an outage nobody can attribute. The same shape, in `services/git_build.rs`, is fired **unattended** by the preview sweep every five minutes. It is guarded now too. ### Fixed — the panel deleted Let's Encrypt certificates it never issued Two paths removed `/etc/letsencrypt/live`, `/etc/letsencrypt/archive` and the `renewal/*.conf` that regenerates them: Docker-app removal, by direct `rm`, and SSL revoke, via `certbot delete --cert-name`. **Neither tree issues through certbot.** Certificates the panel provisions go to `/etc/dockpanel/ssl` through its own ACME client, so every lineage those deletes could reach had been created by the operator, out of band — and a certbot lineage carries all its SANs, so deleting the one named `example.com` took `www.` and `mail.` with it, along with the automation that would have renewed them. On a box whose mail stack the panel configured, that includes the lineage Postfix and Dovecot are pointed at, whose documented fallback is the distro's self-signed snakeoil. Nothing distinguished a panel-era lineage from an operator's, so there is no safe narrowing. Both deletes are gone. Revoke now reports that a lineage of that name exists and leaves it alone. ### Fixed — one tenant could destroy another tenant's systemd unit, jail and crons Three separate keys were not keys at all: - **Systemd units and Fail2Ban jails.** `domain.replace('.', "-")` maps `a.b.com` and `a-b.com` onto one name, and `-` is legal inside a domain label, so both are separately claimable and both resolve to `dockpanel-app-a-b-com`. Deleting one site stopped, disabled and unlinked the other's app unit, and removed its jail; creating one silently overwrote the other's unit with the wrong `WorkingDirectory`, latent until the next reboot. Both now read the file — a unit records the docroot it runs, a jail records the log it watches — and refuse when it names someone else. - **Cron sync.** The `# dockpanel:` marker is panel-wide, but the payload was one site's rows. So any tenant touching a single cron on their own site stripped **every other site's jobs** from the crontab, box-wide and silently: the `crons` rows are untouched, so the panel kept listing them as enabled while they never ran again. The payload now carries the site's full cron set, and the sync removes only the lines whose ids it was sent. - **The WordPress auto-update marker** was matched as an unanchored substring, so toggling auto-update on `example.com` — or simply deleting that site — stripped `example.community`'s line and silently ended its core, plugin and theme security updates. ### Fixed — a Traefik route removal deleted a different, live domain's route v2.52.0 made the route filename injective by escaping `-` before mapping `.` to `-`, then kept deleting the pre-fix name as upgrade cleanup, under a comment reading *"Only safe because the legacy name is what THIS domain would have been written as."* That premise is true and insufficient: the legacy name is also what **another domain is written as today**. `route_key` doubles a literal `-`, so for a domain containing none it agrees with the old mangle — the legacy name of `a-b.com` is exactly the current file of `a.b.com`. Removing the first app took the second one's route down, on a **fresh install with no legacy files at all**, and the window never closed. The cleanup now reads the file's own `Host()` rule first. ### Fixed — deleting one site could take the whole box's nginx down at the next restart A DNS-01 wildcard is provisioned once under the zone apex and every site in the zone points `ssl_certificate` at that one directory. Deleting any one of them removed it. Nothing failed at the time, because nginx was already serving from memory — it failed at the next `nginx -t` anyone triggered, and left nginx down for **every** site on the box at the next full restart. Four paths shared the defect; each now checks whether another vhost still references the directory. ### Fixed — site deletion and rename touched other accounts' status-page components `DELETE FROM status_page_components WHERE name = $1` had no `user_id`, and the rename path had the same gap on an `UPDATE`. The owning module's own delete keys on both the id and the account; these two had dropped the tenant filter, so they reached across accounts and took the monitor links with them by cascade. ### Fixed — a rename overwrote an operator's vhost and deleted it on rollback `rename_site` is the replacing writer the v2.52.0 `restore_or_remove` retrofit missed. It wrote the destination config with no snapshot and, when the whole-server `nginx -t` failed, bare-deleted it — and the destination is a path `domain_claim` cannot vouch for, because that guard clears a name against `sites`, `git_deploys` and Docker-app labels and never looks at the filesystem. A vhost the operator wrote by hand was therefore claimable, silently replaced, and then destroyed with nothing to restore from. ### Fixed — a slow `crontab -l` replaced root's entire crontab `read_crontab` collapsed a timeout, a spawn failure and a genuine "no crontab" into the same empty string, which the caller then wrote back as the complete new crontab. A single slow read destroyed every operator and system entry on the box. An empty crontab is now returned for exactly one reason. ### Added - `services::ownership` — the one place the question is answered, with a three-state result. `Unknown` (unreadable, or carrying no marker) does **not** permit a delete; the asymmetry is the design. - `tests/ownership-delete-pin-e2e.sh` — 36 assertions, 27 of them red against v2.52.0. §0 measures the comment stripper against its own subjects and C0 asserts the file list is non-empty, because both of this suite's absence sections are green and worthless on an empty subject. Attacked with 24 evasions; seven beat the first draft, including both classes that beat the s294 pin. ## [2.52.0] - 2026-07-31 One question nothing owned — *may this domain be claimed?* — and one nobody asked — *what was here before I wrote?* ### Fixed — a site update could delete that site's nginx configuration This is the one to upgrade for, and it has nothing to do with domains. When the agent wrote a vhost it replaced the file at `/etc/nginx/sites-enabled/{domain}.conf` and then ran `nginx -t`. If the test failed it **deleted** the file — under a comment reading *"Invalid config — remove it and restore"*. No backup was ever taken, so there was nothing to restore from, and a second, undocumented delete did the same when `nginx -t` merely timed out. `nginx -t` validates the **whole server**. An unrelated broken vhost anywhere on the box fails it. So an ordinary self-service change on a healthy site — switching PHP version, toggling the WAF, editing security headers — could remove that site's configuration file, as could the auto-healer and the security scanner with nobody touching anything. Nginx is not reloaded on that path, so the box kept serving from memory and the loss only surfaced at the next reload. All three writers now snapshot the existing configuration first and put it back on failure, deleting only a file they themselves created. The error now says so. ### Fixed — one guard decides whether a domain may be claimed Eleven paths could cause a vhost to be written and each carried its own subset of the checks. `sites.rs` had a shared helper whose comment said it existed *"so the guard set create() enforces cannot drift"* — and two of the eleven called it. - **Docker apps were invisible to every guard.** An app's domain lives only in the `dockpanel.app.domain` container label, never in the database, so no SQL check could see it. Creating or renaming a site onto a domain an app owned passed every check and then replaced the app's vhost. The panel now asks the agent, which has been returning that domain all along. - **`POST /api/apps/deploy` checked nothing at all** — not the reserved control-plane domain, not sites, not git deploys. It could be pointed at a live site's domain, or at the panel's own hostname. The check now runs before the DNS record is created, not after. - **Renaming a git deploy** skipped both conflict queries that creating one performs, so a domain that could not be created could still be renamed onto. - **Staging environments** — the only tenant-reachable path here — consulted the sites table alone, and accepted any domain, not just a subdomain of the parent. - **Preview deployments** built `{branch}.{domain}` from a pushed branch name and claimed it unchecked. A branch called `www` took `www.example.com`. Previews now deploy without a vhost rather than over someone else's. - **The migration wizard** imported client-supplied domains with no format or reserved-domain check at all. - **`EXAMPLE.com` walked past `example.com`.** Domain comparison was case-sensitive while the reserved-domain check was not. Domains are now normalised at the point of claim. ### Fixed — two Traefik route files could collide The route filename and router name mapped `.` to `-`, which is not reversible: `a.b.com` and `a-b.com` both became `a-b-com`. The second app deployed silently truncated the first one's route, removing either deleted the other's, and the TLS state reported for one was the other's. Route files written by an older agent are cleaned up on removal. ### Added - **Domain Watchdog** as a multi-container stack package (Docker Apps → Compose → Packages) — RDAP domain monitoring, four services (#50). ### Testing - New `domain-claim-pin-e2e.sh`, 44 assertions, 29 of them red against v2.51.0. Sixteen deliberate evasions were then run against the fixed tree; two beat the first draft — a delete reached through a renamed variable, and a guard whose result was computed and discarded — and both arms were re-keyed on the property rather than the spelling. - **A comment stripper shared by five pin suites was deleting code.** `/*` inside a string literal (a Dockerfile's `COPY … /app/target/release/*`) opened a block comment that ran to the next `*/`: `git_build.rs` lost 485 of 1214 lines and `agent/routes/nginx.rs` 118 of 2263. A truncated subject makes an absence-checking assertion pass on code the stripper merely removed. Fixed in all five; every suite re-verified green. ## [2.51.0] - 2026-07-31 Two ends of the panel disagreeing about something each of them already knew. Both were found while working through GitHub issues that had gone unanswered, and both turned out to be wider than the reports that led to them. ### Fixed — the scheme is derived, never assumed (#96) DockPanel printed and fetched `https://` in five places while the answer was in scope. For anyone terminating TLS at a proxy in front of the server — where the origin only ever serves plain HTTP — every one of them was wrong. - **The Docker Apps link** now follows the scheme the server actually serves. The agent reports it per app, read from the vhost it wrote itself, or from the Traefik route config when Traefik is the proxy. - **The uptime monitor created for a new app** was pointed at `https://` unconditionally — including on a deploy that had just reported *"SSL certificate — Skipped"* on screen a moment earlier. It now follows what the deploy reported, so a deploy without a certificate no longer creates a monitor that can only ever fail. - **The post-deploy health check for a git deploy** assumed https, under a comment saying git deploys "typically" have SSL. On a deploy without a certificate it connected to a port serving no TLS and reported a perfectly good deploy as unreachable. The GitHub commit-status link had the same assumption and now shares one resolver with the health check. - **Renaming a site's domain** rewrote its monitor's URL to `https://`. A rename should change the domain; it now leaves the scheme alone, which also stops it overwriting a URL the operator had set by hand on the Monitors screen. Added: **"TLS is terminated by an upstream proxy"** on the app deploy form. The SSL email field was labelled optional but was not — leaving it blank substituted your account address and requested a certificate anyway, so an operator behind an external terminator had no way to decline and spent a doomed ACME attempt on every deploy. The default is unchanged: leave the box unticked and you still get a certificate. ### Fixed — the Migration Wizard imported into a directory nothing serves (#51) Found while answering a three-month-old request for more source panels. The wizard advertises cPanel, Plesk and HestiaCP, and on a realistic archive none of the three could complete an import: - **Every imported site landed one level above its own document root.** The vhost the wizard creates serves `/var/www//public`; the importer copied the site's files to `/var/www/` and its `rsync --delete` then removed the `public` directory that had just been created. The result was a site the panel reported as imported, serving a document root that did not exist. Both halves now ask one function where a site lives. - **Nested archives could not be found at all.** The analyser steps into the single top-level directory that a cPanel full backup and a `cpmove` archive both nest everything under; the importer rebuilt the path without that step, so it looked one directory too high and failed with "Source directory not found". Both sides now resolve the archive root through the same function. - **Plesk and HestiaCP emitted absolute paths**, which the importer's own path-traversal guard rejected — so every site from those two failed on "Invalid source directory path" — **and bare database filenames** without their directory, which resolved one level too high and failed on "SQL file not found". ### Added - `tests/scheme-and-import-pin-e2e.sh` — 35 assertions, 32 of which fail against the previous release. Fourteen deliberate evasions were then written against the fixed tree; five walked through the first draft and each one is now closed. ## [2.50.0] - 2026-07-31 Eleven backup defects were recorded as findings at v2.48.6 and left unbuilt. All eleven were re-verified against this tree before any work started; none had aged out. Nine are fixed here. The theme is that the backup subsystem was telling the truth about almost everything except whether it had worked. ### Fixed - **The Restore Confidence card had been dead on every install since it shipped.** Two of its queries selected `server_id` from `backups`, a column that table has never had, so both failed at plan time on every request and their errors were discarded. The card showed "No recent backups to verify yet" forever, and took the verify-lag percentiles, the oldest-unverified age, the drill strip and the per-server breakdown down with it. Site backups now reach their server through `sites`, as the neighbouring list query always did. - **A measurement that fails is no longer reported as a measurement of zero.** The health response carries `sla_unavailable`, and the card says the figure could not be computed instead of rendering its empty state. - **The stale-backup warning went blank exactly when it mattered.** Its query deliberately selects sites that have never been backed up, then decoded the resulting NULL into a non-nullable timestamp — and since those rows sort first, the failure took the whole list, including sites that were genuinely overdue. Never-backed-up is now a state the list can express and render. - **A backup the panel could not record was still counted as a success.** Five writers discarded the result of their `INSERT`. A backup destination deleted while a policy was running made every later insert a foreign-key violation, and the run still reported green over archives that exist on disk and in no list or restore path. All five now report the failure; on the manual path the live log says so instead of ticking "Backup created". - **Clearing a destination's secret field destroyed the stored credential.** An empty string was neither encrypted nor recognised as the "keep what is stored" sentinel, so it replaced a working key with nothing. Empty secrets are refused. Separately, an edit no longer drops stored settings the form does not send, such as an SFTP key path. - **Creating or updating a destination echoed its stored secret back.** Both handlers returned the row unmasked while the list endpoint masked it — and on panels that predate credential encryption the stored value is the real secret, so renaming a destination handed it to whoever was watching the response. - **Policies could be created and deleted but never edited.** Schedule, scope, retention, destination, encryption, verification and enabled were all create-once, while the panel's own preflight advice said to edit the policy and pick a destination. There is now an Edit control, and the endpoint behind it no longer resets `server_id`, `destination_id` or `retention_count` when a request omits them. - **"Protect Everything" now exists.** The preset the Quick Start guide and the preflight remediation both tell you to click was fully implemented and had no button. - **Retention that cannot be enforced is reported.** The agent answers that it cannot prune an SFTP destination; both callers discarded that answer, so remote copies accumulated while the panel displayed the retention count as settled. It now reaches the system log, once per run, and the policy form says so at configuration time. ### Changed - **The policy "Encrypt" option is now "Encrypt DB dumps", because that is what it does.** The agent has no encryption path for site or volume archives, so a ticked box left them in cleartext on disk and at the destination — and since v2.34.0 a site archive also contains a dump of every database attached to that site, so an encrypted policy shipped an encrypted copy of that data and an unencrypted copy of the same data to the same place. The label, the guide and the policy form now say what is covered; encrypting those archives for real needs agent-side work on the create, restore, list and prune legs. - Restic incremental backup is marked API-only in the README. It is implemented end to end and has no panel UI. ### Added - `backup-truth-pin-e2e.sh` (35 assertions, CI job `backup-truth`). Its arms are keyed on the capability a regression must use rather than on today's spelling; 34 of 35 fail against the previous tree, and seven deliberate evasions were run against it, the last of which found a real gap in the suite itself. - `docs-claims` now compares the pin suites on disk against the rows published in `docs/testing.md`. Every earlier check walked only the rows the page lists, so a suite that was never added there was invisible to all of them. The new arm immediately found `tier2-pin-e2e.sh`, unpublished since it landed. ## [2.49.1] - 2026-07-31 A security fix. One in-memory map held the progress logs of nine unrelated features under a single flat set of ids, and the endpoints that read it did not agree on who was allowed to. ### Security - **A provisioning log could be read by accounts it did not belong to.** `state.provision_logs` is one process-wide map keyed by bare uuids — a site id, a backup id, a deploy id and a migration id all live in it together with nothing to tell them apart. Five endpoints served from it and each decided authorization differently. Two of them got it wrong: `GET /api/services/install/{id}/log` took the admin extractor, **discarded the claims**, and looked the id up with no ownership test at all. Despite its path it is the panel's general progress stream — the UI points backup, restore, site-deploy, mail-install and system-update ids at it — so it could not authorize by feature, and authorized by nothing. `GET /api/git-deploys/deploy/{id}/log` consulted the owner table but **fell through when the id was absent from it**, on the stated grounds that the not-found below would catch it. It would not: absent-from-owners and absent-from-logs are different questions. Only 3 of 16 places that created a log ever recorded an owner, so absent was the ordinary case — and this route requires only a signed-in session, any role. That matters because one of those streams carries a credential on purpose. A one-click WordPress install emits the generated admin password in cleartext on its `credentials` step, so the operator who did not choose one still gets it. The comment justifying that pointed at the owner check on the *sites* endpoint — true there, and not true of the siblings reading the same map. Measured on a throwaway server running 2.49.0: the site's owner, an unrelated tenant with the ordinary `user` role, and a second admin all attached to one site's provisioning stream and received **the same 1458 bytes, containing the same cleartext WordPress admin password**. Ownership now travels with the key rather than with the endpoint. A log cannot be created without recording who it belongs to, and there is one read path, which refuses any id whose owner is not recorded — so a future feature that forgets gets a stream nobody can read, instead of one everybody can. "Not yours" and "no such log" answer identically, so the endpoint cannot be used to discover which ids are live jobs. The two readers that exempted the admin role no longer do: `/api/sites` is scoped per user, so holding that role was never a licence to read another tenant's log. **If you run more than one account on a panel, update.** No configuration change is needed. Reading a log still requires a valid session; there is no unauthenticated exposure. ### Fixed - **Owners of backups and site deploys can read their own progress logs again.** The same endpoint required the admin role while the routes that *start* those jobs only require ownership, so an ordinary user could launch a backup and then be refused the log of the job they had just started. Checking the key's owner is both narrower than the old role check and correct for this case. - **A progress stream that ended without reporting anything is no longer drawn as success.** The log component treated any stream that closed before its first step as a completed one — a green tick over an empty list. It now says the stream ended without reporting, and distinguishes that from a finished job. - **The owner table is pruned on every sweep.** Pruning had been conditional on the hourly expiry having evicted something, but each feature retires its own log within 30-60 seconds, so that sweep usually finds nothing and the prune rarely ran. Survivable while three call sites recorded an owner; not now that every one does. ## [2.49.0] - 2026-07-30 Two long jobs that the panel had been holding an HTTP request open across, and a capability that was complete except for the one link that made it reachable. ### Fixed - **Analysing a cPanel backup no longer times out (#91).** The analysis ran inline in the request. Walking a real cPanel account is minutes of work, and every gateway in front of the panel gives up sooner: Cloudflare at 100s, the nginx the installer writes at 300s, and the panel's own request ceiling at 300s — while the agent call was budgeted 600s. So the connection was torn down for exactly the archives the wizard exists to import, and the reporter's `Request failed (524)` was this repository's own fallback message rendering a Cloudflare error page that carried no JSON to read. The work always carried on afterwards; nothing was able to come back and say so. `POST /api/migration/analyze` now returns **202** immediately and the verdict lands in the migration row — `analyzed` with an inventory, or `failed` carrying the agent's own sentence. The wizard polls it, shows elapsed time, and picks a running analysis back up after a reload, so closing the tab no longer loses sight of a job that is still going. Raising a timeout was available and would only have moved the archive size at which this breaks. - **Unpacking an archive is bounded on the side doing the unpacking.** Neither side of the socket had a limit on `tar`, so a damaged member or a stalled disk could only end by a gateway hanging up on the browser. The agent now owns a 30-minute ceiling on extraction, kills a timed-out `tar` rather than merely stopping waiting for it, removes the scratch directory, and names the archive and the budget in the error. The panel's budget sits deliberately above the agent's, so the sentence the operator reads is the one written by the side that knows what happened. - **A restart no longer leaves a migration spinning forever.** Analysis and import both run in a spawned task, which a restart takes with no chance to write a verdict — leaving a row claiming to be in progress and a UI that could never clear it. On boot nothing is running, so anything still claiming to be is closed out and says why. - **The PHP version picker can install a PHP version.** Reported by surprises29, who expected switching version to offer the install. The version-specific installer has existed end to end since v2.8 — the agent route, deb.sury.org for Debian, `ppa:ondrej/php` for Ubuntu, module streams for the RHEL family, and a backend proxy — and **nothing in the browser had ever called it.** Both pickers offered five versions with no idea which of them the server had, and choosing one it did not have failed the switch, or the whole site creation, at the agent. Both now read the live list, label what is missing, and offer to install it, with the install's log streamed as it runs. The switch is held back until the version actually works rather than being fired and refused. - **The advice that refusal used to give was a dead end.** It sent people to Settings → Services, which has one PHP tile with no version on it, reports installed if *any* version is, and therefore shows a box that already has 8.4 an "Uninstall" button and no install button at all — over a route that takes no version and installs whatever the distro offers. It now names the control that can do the job. - **Installing a service is no longer capped at 60 seconds.** `install_service_with_log` used the untimed agent client over an operation the agent budgets 300s for, plus repo configuration outside that clock. Nothing was cancelled by it: the install finished while the panel wrote *"agent request timed out after 60s"* into the log the operator was watching. This affected **every** service install, not just PHP — the s289 defect again, at a different call site. - **An install that leaves PHP unusable is no longer reported as success.** The already-installed branch answered from the package database, while the guard that sends people there tests the **socket**. A box whose `php8.3-fpm` was installed and stopped was told "already installed" and then failed the very next action with the same message. All three install paths now start the unit and judge the outcome on whether the socket appears. - **A test arm that had stopped being able to fail.** `docs-claims` verifies that the testing page's summary sentence matches the table under it. Its list of English number words stopped at "twelve" while the table grew past twenty, so the suite-count half silently skipped its own check and agreed with whatever was written. Restored — and it immediately caught that the same check could not read a hyphenated number either, having been reading "Twenty-two" as "two". ### Added - Two regression pins, 57 assertions, both watched failing against the pre-fix tree: `migration-analyze-async-pin-e2e.sh` (28) and `php-install-from-picker-pin-e2e.sh` (29). ## [2.48.6] - 2026-07-30 A backup that the panel calls off-site is now actually off-site. v2.48.5 fixed the backup credential path and proved **Test Connection**; it never watched a backup **arrive**. Driving one to the destination found six defects underneath, and four of them make the panel state something false about data protection. ### Fixed - **A redirect is no longer reported as a completed upload.** The S3 uploader passed `curl --fail`, which fails on 4xx/5xx and says nothing about **3xx** — and without `-L`, curl does not follow a redirect: it transfers nothing and **exits 0**. A bucket addressed in the wrong region, or an `http://` endpoint the provider redirects to `https://`, therefore produced a successful upload with no object written, the agent answering `{"success":true}` and the panel lighting the **remote** badge. Measured against a redirecting endpoint: a full policy run reported *"1 successes, 0 failures, 0 not uploaded off-site"* while the bucket stayed empty. Success is now an explicit 2xx. Following the redirect is deliberately **not** the fix — an AWS SigV4 signature is bound to the host and path it was signed for, and curl downgrades `PUT` to `GET` on 301/302. The same latent bug was in `test_s3`, `list_s3` and `delete_s3`; all four now share one runner rather than four copies of the flags. - **A scheduled backup records where its bytes went.** `backup_scheduler` computed its upload result into a discarded `_`-prefixed binding and inserted five columns, omitting `uploaded` and `destination_id` — the two columns the migration had added for precisely this. Every per-site scheduled backup that *did* go off-site was filed as local-only, so the **remote** badge could never appear for the path most people configure, and nothing recorded which destination held the copy. Measured: the SFTP archive sitting at the destination while its row read `uploaded=f, destination_id=NULL`. - **An upload slower than a minute is no longer treated as a failure.** The panel capped every agent call at 60s while the agent budgets 600s for this exact upload. An off-site copy that took longer — a 12MB archive on a slow uplink was enough to measure — had the panel give up while `curl`/`scp` kept running, so the bytes landed and the panel then re-sent the whole file twice more, recorded it local-only, tripped the destination breaker so every remaining site, database and volume in that run skipped off-siting too, and raised an incident saying the backups *"exist only on this server"*. Both upload call sites now use `post_long` with a budget that outlasts the agent's, so the agent's own error surfaces instead of a bare timeout. - **`sshpass` is installed.** It is the only path a password-authenticated SFTP destination can take, and password is the mode the destination form offers first — but nothing installed it, so on every fresh install those destinations failed at Test Connection with an opaque `502`. v2.48.5 measured SFTP as working only because the test rig had installed `sshpass` itself. The installer now installs it (best-effort: it comes from EPEL on RHEL-family, and making it mandatory would turn a missing repository into a failed install), `openssh-client` is declared rather than assumed, and when it is genuinely absent the agent now names the binary and the remedy instead of reporting `os error 2`. - **Test Connection exercises what an upload needs.** The S3 test issued `HEAD` on the bucket **root** while an upload `PUT`s into the **prefix** — a different permission at a different path, so read-only keys and prefix-scoped keys both reported green. It now writes a probe object where the backups will go and removes it. The SFTP test connected and ran `exit`, never touching `remote_path`; and nothing ever created that directory, which `scp` will not do — so a destination whose path did not already exist passed Test and failed every upload with *"No such file or directory"*. The default is `/backups`, which exists on almost no server. The upload now creates the directory, and the test creates and probes it. - **One `known_hosts`, not two.** v2.48.5 pointed the backup uploader's ssh at `/var/lib/dockpanel/known_hosts`, when `deploy.rs` had already established `/etc/dockpanel/known_hosts` for git deploys, for exactly the same reason and with a docstring saying so. Two trust stores means a host pinned by a git deploy is an unknown host to a backup upload. Aligned to the existing path. (Both are writable under the unit; this is consistency, not a failure.) ### Changed - **The ssh options for backup uploads are built in one place.** `upload_sftp` and `test_sftp` each built the list by hand under a comment promising they were "kept in step deliberately". They were not — `ConnectTimeout` was set on the test and missing from the upload — and a test that connects differently from the upload is a test of nothing. One builder now serves both. ### Added - **CI check: `backup-lands`.** A source pin over all six defects above, watched failing against the pre-fix tree (29 of 31 arms) before being trusted. ## [2.48.5] - 2026-07-30 SFTP backup destinations have never worked. Both reasons were found by standing one up and pressing Test — neither is visible in the code that implements SFTP. ### Fixed - **An SFTP destination can be reached at all.** `ssh` was invoked with `StrictHostKeyChecking=accept-new`, which means "trust on first use, then pin" — and pinning is a **write**, to `~/.ssh/known_hosts`. The agent runs with `ProtectHome=yes` and `ProtectSystem=strict`, so that path does not exist and cannot be created: ``` Could not create directory '/root/.ssh' (Read-only file system) ``` Every SFTP destination failed there, before it opened a connection — so none could be tested and none could be uploaded to. `known_hosts` now lives at `/var/lib/dockpanel/known_hosts`, which the unit already allows. First-use pinning is kept; `StrictHostKeyChecking=no` would have "fixed" this by throwing away host verification. - **Password authentication works.** The same commands also passed `BatchMode=yes`, which disables password and keyboard-interactive authentication outright. `sshpass` was therefore supplying a password that `ssh` had already refused to offer, and the server answered `Permission denied (publickey,password)`. Two settings that are each correct alone and cancel each other out. `BatchMode` is now sent only when authenticating by key, where it belongs — password auth stays non-interactive because sshpass supplies it over a pty and `ConnectTimeout` bounds the attempt. Measured both ways against a live endpoint: with the flag, denied; without it, connected. ## [2.48.4] - 2026-07-30 Two defects that only became visible once something finally exercised the code: one endpoint that had never had a caller, and one feature nobody could see the error from. ### Fixed - **Editing a backup destination no longer destroys its stored credential.** `PUT /api/backup-destinations/{id}` merged the mask sentinel by copying the **already-encrypted** value into the config and then encrypting the whole object again. `encrypt_config_secrets` skips only `""` and `"********"`, and a stored ciphertext is neither — so the secret was encrypted twice, one decrypt on the way out returned ciphertext, and the destination authenticated with gibberish while the row looked perfectly normal. It had never fired because that endpoint had no caller anywhere in the panel until v2.48.3 gave the Destinations tab an Edit button. The first edit of any destination would have corrupted it. The order is now inverted — encrypt what arrived, then carry masked fields across verbatim — and a masked field with nothing stored behind it is dropped rather than passed through as the literal string `********`. - **SSH hardening works at all** (reported on #92). Disable root login, disable password authentication and change SSH port all rewrite `/etc/ssh/sshd_config`, and `/etc/ssh` was missing from the agent unit's `ReadWritePaths` while `ProtectSystem=strict` is in force. Every one of them failed with `Failed to write sshd_config: Read-only file system (os error 30)` and the panel answered 502 — on **every install since the sandbox landed**. This is the third time this exact enumeration miss has shipped (`/etc/apt`, `/var/spool/cron`, now `/etc/ssh`), so the unit now carries a regression pin rather than another comment: `tests/agent-sandbox-paths-pin-e2e.sh` derives the paths each `safe_command`-adjacent writer touches and fails when one is absent from the unit. Updating reinstalls the unit, so `scripts/update.sh` is enough. Worth noting the failure was only *legible* because v2.48.2 stopped flattening the agent's message; before that it read "Agent offline". ## [2.48.3] - 2026-07-30 Two reports, one shape: a feature whose halves were both present and were not connected to each other. Neither was a missing capability — in both cases the work had been done and something in between dropped it on the floor without saying so. ### Fixed - **Git Deploy now passes the UI's environment variables to the container** (#94). The panel sent them under the key `env_vars`; the agent's handler declared that field as `env` with `#[serde(default)]`. serde discarded the key it did not recognise and defaulted the one it did to an empty map, so the container started with no environment and every layer reported success. Only `docker inspect` disagreed. This was never specific to the Dockerfile build method. It affected Nixpacks too, and redeploys, and rollbacks, and PR previews — all five call sites. Nixpacks merely *looked* correct because the build endpoint receives the variables separately and bakes them into the image. Docker Apps was unaffected because its two sides happen to agree on `env`. The agent now accepts either spelling, so a panel and an agent on different versions still understand each other. All five bodies are built by one function whose parameters are not optional. - **A crashed Git Deploy container no longer rolls back unconfigured and unbounded.** The auto-rollback path built its own request from four fields and had lost three: it sent no environment under either spelling, and no `memory_mb` or `cpu_percent`. A container that crashed and rolled back came back with no environment *and no resource limits* — on the one path nobody watches, because it runs unattended after a failure. - **Backup destinations can be added from the panel again** (#93). The S3/SFTP form — create, list, test, delete — was written in Settings, then switched off in place behind `{false && (…)}` by the commit that moved the Destinations tab to Backup Manager. That move brought across the list and the Test button and left create, update and delete with no caller anywhere in the panel. The empty state went on telling operators to "add one via Settings", which was by then the one screen the control had been taken from; the guidance layer told them to add a destination and linked to a tab that had no way to. Backup Manager → Destinations now creates, edits, tests and deletes, and the page honours `?tab=` so the links the panel already emitted land where they say. Secret fields come back masked and stay stored unless retyped. - **Scheduled backups can actually authenticate to their destination.** Three places hand a destination to the agent and only one of them decrypted first. Test Connection used the decrypting path; the per-site scheduler and the policy executor both cloned the stored row and posted the **ciphertext** as the S3 secret key or the SFTP password. A destination therefore verified green and then failed every real upload. All three now share one helper. - **A destination can be attached to a per-site backup schedule.** The check inner-joined `servers` on `backup_destinations.server_id` — a nullable column that `create` never populates, so it is NULL on every destination the panel has ever made. An inner join on an always-NULL column matches nothing, so the endpoint answered `403 Destination not found or not owned by you` for every destination that existed. Unscoped destinations are now accepted; ones pinned to a server still have to belong to the caller. ### Known issues - PR preview containers still run without the parent app's memory and CPU limits. That predates this release and is left unchanged deliberately rather than altered as a side effect of the rollback fix. - Nixpacks still passes environment variables to `nixpacks build --env`, so secrets land in image layers and are visible to `docker history`. Runtime injection now works, which makes the build-time copy redundant; removing it is a separate change. ## [2.48.2] - 2026-07-30 Three people reported three different bugs this week. All three had been told "Agent offline — the DockPanel agent is not responding" by a panel whose agent was answering perfectly clearly. ### Fixed - **An agent that refuses a request is no longer reported as an agent that is down** (#90, #91, #92). `agent_error()` accepted `impl Display`, so `AgentError::Status(code, body)` — carrying the agent's real status and its real sentence — was formatted into a string and collapsed into one generic `502` at 244 call sites. The frontend then rewrote *any* 502 into the "agent offline" message. So `Path must be within /var/backups/ or /tmp/`, `WAF not installed. Install it from the Services page first.` and a complete `nginx -t` diagnostic all reached their operators as the same dead end. A 4xx from the agent now keeps its status and its message. Only a transport failure or an open circuit breaker — the cases where nothing answered — carry the `agent_unreachable` marker the UI keys off. Agent 5xx still returns a generic message with an incident id, so internals are not leaked. - **Stripe and Cloudflare failures no longer blame the agent.** 27 call sites in `billing.rs` and `dns.rs` routed third-party HTTP errors through the agent path; `billing.rs` makes no agent call at all. They now report which upstream actually failed. Enforced by the type system rather than by review: `agent_error` takes `AgentError`, which a `reqwest::Error` is not. - **The WAF install no longer leaves nginx unable to start** (#92). The installer copied `unicode.mapping` from `/usr/share/modsecurity-crs/`, and when that was absent wrote a **zero-byte file** — which ModSecurity cannot parse. Debian 13's `libmodsecurity3t64` ships no `unicode.mapping` anywhere, so every Debian 13 install took the empty-file path, reported success, and failed `nginx -t` the first time a site actually enabled the WAF. The mapping now rides the binary (`include_str!`), and the installer verifies the file it just wrote defines the code page `modsecurity.conf` names. - **The Migration form stops suggesting a path the agent rejects** (#90). The Backup File Path placeholder read `/home/user/backup-…` while the agent only accepts `/var/backups/` or `/tmp/`. The field now shows an accepted location and states the constraint. ### Known issues - **#91 (Migration `524`) is only half addressed here.** The misleading message is fixed, but the underlying cause — backup analysis runs inline in the request and can outlive the gateway timeout on a large archive — needs the async 202-and-poll treatment used by the restore and update paths. Tracked on the issue. ### Added - `tests/agent-error-propagation-pin-e2e.sh` (21 assertions, CI job `agent-error-contract`), proven to fail 20 of 21 against the pre-fix tree. ## [2.48.1] - 2026-07-29 A clean 16-package upgrade reported itself as failed, because the update killed the process that was reporting on it. ### Fixed - **The agent no longer gets restarted out from under its own update.** A panel-driven update runs apt from inside `dockpanel-agent`; because the agent links libc, an ordinary `libc6` upgrade put `dockpanel-agent.service` on needrestart's restart list. needrestart then SIGTERMed the process that was streaming that very update's progress. apt itself was untouched — it runs in a `systemd-run` transient scope — but the NDJSON stream died before its final `done` line, so the panel reported a fully successful upgrade as *"Update completed with errors"*. needrestart already declines to restart the unit that invoked it, precisely to avoid this; the escape hatch that lifts apt out of the agent's sandbox also lifts it out of the agent's cgroup, so that protection never applied. `setup.sh`, `update.sh` and `install-agent.sh` now drop a needrestart override excluding only `dockpanel-agent` — every other service still restarts, which is the part that matters for security. The agent picks up new libraries on the next reboot or agent restart. - **Streamed operations are now bounded by silence, not by total runtime.** The update stream carried a flat 300s cap, and the remote client additionally inherited reqwest's 60s total-request timeout. Neither can be set correctly for a stream: `apt-get upgrade` finishes in 40s on a fast box and can legitimately run far longer on a slow link with a full release of packages, so long updates were failed while apt carried on underneath. The bound is now 10 minutes of *no output*, rearmed on every line. - **The update terminal reconnects instead of silently going dead.** A dropped SSE connection closed the stream and left the page frozen mid-output with no explanation. The backend already replays the whole log on reconnect, so the terminal now retries, rebuilds itself from the replay, and — if it really cannot reattach — says so in amber, making clear the update is still running on the server rather than implying it failed. - Finished updates' logs are kept 15 minutes instead of 60 seconds, so an operator who lost the connection can still come back and find the result. - `install-agent.sh` also now writes the apt lock-wait drop-in that `setup.sh` has always written, so fleet members stop failing agent-driven installs that race unattended-upgrades. - The remote client records a successful round-trip only once a stream has actually completed; it previously counted one the moment response headers arrived, so a connection that died mid-body still looked healthy to the circuit breaker. ## [2.48.0] - 2026-07-28 Fourteen settings the panel read but no screen could set — including the OAuth client credentials behind the sign-in buttons — now have controls, and the check that was supposed to have prevented that now exists. ### Added - **OAuth sign-in is configurable from the panel.** The client ID and secret for Google, GitHub and GitLab were writable through the settings API, masked on read, encrypted at rest and consumed by the login route — every part of the path built except a screen to type them into. Settings → Account now carries a card per provider, with the state of each shown plainly (Active / Incomplete / Not configured). It also prints **the exact redirect URI to register at the provider**, fetched from the server rather than composed in the browser: the panel builds that URI from `BASE_URL`, and an admin browsing on a different address would otherwise be shown, and would register, a URI the panel never sends. When `BASE_URL` is unset the card says so — until now that produced a relative redirect URI, which no provider accepts, with nothing anywhere explaining why sign-in failed. Saving a client ID without a secret is refused rather than accepted. The public branding endpoint lists a provider as soon as its client ID is non-empty, so a half-configured save puts a working-looking button on the login page for every logged-out visitor, which then dead-ends at the callback. - **Notification templates have an editor.** The four `notif_template_*` keys are read on every alert and substituted into; they had no control, so the feature was reachable only by whoever could hand-craft a PUT. Settings → Alert Channels now has one field per channel, with the four supported placeholders listed. - **Stripe plan price IDs have inputs.** Checkout answered "Price not configured for the pro plan — set it in settings" while no page could. The card also reports when `STRIPE_SECRET_KEY` is absent from `api.env`, because that half of billing is not a panel setting and the price IDs do nothing without it. - **A white-label toggle.** `hide_branding` was returned by the branding endpoint and honoured by the sidebar, the header and the login page — a switch three surfaces obeyed and no screen could throw. ### Fixed - **The verdict of a failed update is no longer the last thing on the page.** The update and rollback result cards rendered inside the Snapshots panel, seventh of seven on the Updates tab, so an operator whose update had just failed scrolled past six cards to reach the sentence explaining why the panel was stopped. They are now first, ordered by time so the newer of the two leads. - **`ALLOWED_KEYS`' comment claimed a test that did not exist.** It said the pin suite "fails when [an entry has no control]". The suite discovered the frontend's key list into a variable and never read it — so the drift it named was the drift it could not see, and fourteen keys accumulated behind a green run. The arm now exists and fails against the pre-fix tree naming all fourteen. It tests that the frontend *sends* a key, not that it mentions one: a read is not a control, which is exactly how `hide_branding` stayed invisible while three components read it. Keys a purpose-built route writes instead — the nginx/Traefik selector, which posts to `/traefik/install` — are discovered from the backend rather than exempted by name. ## [2.47.4] - 2026-07-28 Closes the two remaining defects in the update subsystem. One could silently undo a rollback; the other made a failed update look like an update still running. ### Fixed - **A failure while finishing a rollback could quietly undo that rollback.** The restore reverts the database, then puts the previous binaries back. Between those two steps it verifies the restored schema and records the rollback — and if either step failed, the exit trap restarted `dockpanel-api` to avoid leaving the panel down. But the binary it restarted was still the *newer* one, so it migrated the just-reverted database forward again. The panel came back on the schema the operator had rolled away from, while the result file reported the rollback as FAILED. Three changes. The steps after the database commits now report rather than abort, because once the transaction has applied there is nothing left to protect by stopping and a box stranded between two versions to lose. The exit trap now refuses to start the API while the database has been reverted and the binaries have not, leaving it stopped deliberately and naming the pre-rollback dump to recover from — the panel being down is obvious and reversible, a database migrated forward behind a failed-rollback verdict is neither. And the API binary is restored first, so the window is as short as it can be. - **An update that failed early reported nothing at all.** `update.sh` re-execs itself into a transient systemd unit so it survives stopping the service it was launched from. That hands off in about 29ms while the real update runs for around a minute, so the panel was reading the exit status of the handoff rather than of the update. For an update that succeeds this is harmless — the panel is restarted and works out what happened on the way back up. For one that fails *before* the services are stopped, such as a bad download or a failed database backup, nothing is ever restarted, so nothing ever reported it: the operator watched an update sit in progress, frozen on its first log line, until the fifteen-minute window expired. `update.sh` now records its outcome to `/var/lib/dockpanel/last-panel-update.json` on every exit path — including aborts and signals — and the panel reads that instead of inferring from the process it launched. A completed in-flight rollback is recorded distinctly from a failed one, since a panel healthy on its previous version does not need anyone woken up. The outcome is shown in System → Telemetry beside the rollback verdict. ### Testing - New `tests/update-rollback-pin-e2e.sh` (36 assertions, CI job `update-rollback`). It drives `restore-snapshot.sh` end to end against a scratch tree with stubbed services, so the guard that decides whether to leave a panel stopped is executed rather than merely read. Verified against the previous release's behaviour, where 15 of its assertions fail — including "the API was not restarted", which fails there precisely because the old trap restarts it. ## [2.47.3] - 2026-07-28 Corrects a defect in 2.47.2's own migration — the mechanism that carries 2.47.2's fix to servers that are already running. ### Fixed - **The `/assets/` migration could skip the vhost it was meant to repair.** After rewriting the block it verified its work by searching the *whole file* for the `expires` directive, on the reasoning that the directive should be gone. But `expires` can legitimately appear in another location block — a hand-added `/media/`, for instance — and the check cannot tell which block it came from. On such a server the verification fails, the rewrite is discarded, and the updater reports `failed to rewrite the /assets/ block — skipped`. The check now reads only the block it just wrote. The failure was already in the safe direction — nothing is changed and the skip is logged, so no server was left with a broken configuration — and a vhost generated by the installer has no other `expires`, so reaching it took a hand-edited file. It is corrected because a fix that silently does not arrive is the failure this release series exists to address. ### Added - Eight further assertions in `tests/nginx-headers-pin-e2e.sh` covering the two server populations the suite had not: one already running 2.47.1, where the document block exists and must be neither skipped nor duplicated, and one with `expires` in an unrelated location. Both had been checked by hand, which is precisely the state this project keeps finding does not hold. ## [2.47.2] - 2026-07-28 A security release for a header that was missing from exactly the responses it exists to protect, plus the first regression pin for the whole nginx contract. ### Fixed - **Every panel served its JavaScript and CSS with no `X-Content-Type-Options: nosniff`.** The header was declared at the server level and looked correct anywhere you cared to check — but the `location /assets/` block set a cache header of its own, and in nginx a location block's `add_header` directives *replace* the server block's set rather than merging with it. So the one directory whose responses are scripts was the one directory that lost the header telling a browser not to guess at their type. `/assets/` carried none of the seven headers the rest of the vhost sends. This is measured, not estimated: served through a real nginx, the generated panel vhost returned a bundle with zero security headers. The same shape was in `panel/frontend/nginx.conf` (three of its five) and `website/client/nginx.conf` (none of its four). All three now repeat their own server block's set in full. - **`/assets/` responses carried two contradictory `Cache-Control` lines.** `expires 1y` emits its own (`max-age=31536000`) on top of the block's `add_header Cache-Control "public, immutable"`, so the response went out with both, saying different things. One directive replaces both, and it is deliberately not `always`: an error response must never be cached, or a 404 during an update is remembered for a year. - **The fix reaches boxes that are already running.** As in 2.47.1, a change to `setup.sh` alone would reach only installs created after it, because the updater never re-runs the installer. `update.sh` gained a second in-place vhost migration; like the first, the headers it writes are parsed out of the vhost being migrated rather than taken from the script, so an older install keeps its own policy instead of being silently handed today's on one class of response. A vhost with no server-level header is skipped and logged rather than guessed at, and the migration is idempotent. ### Added - **`tests/nginx-headers-pin-e2e.sh`** — the response contract is now pinned, on both surfaces that can produce it: the template `setup.sh` writes at install time, and the migration `update.sh` applies to a box that installed before the fix existed. It serves them through a real nginx, because this defect class is invisible in the source — a location block that reads fine in isolation silently strips six headers off the response. Every serving assertion proves HTTP 200 *before* reading a header. The harness written for 2.47.1 returned 403 for every request and eleven of its twelve assertions still passed, because these headers are declared `always` and nginx emits them on error pages too. Runs in CI as `nginx-contract`. - **`deploy/nginx/`** — the vhosts for the project's own three public surfaces, under version control for the first time, with a check mode that detects drift against the box. They had been hand-edited files with no copy anywhere, which is how the 2.47.0 document-cache fix came to be applied to one of them and not to the other two that had the identical defect. ## [2.47.1] - 2026-07-28 A delivery release. The fix it carries was written last session and, on its own, could never have reached a panel that was already running — which was every panel except the ones installed after it landed. ### Fixed - **The panel served `index.html` with no cache directive, so an update could be invisible to the operator who applied it.** With nothing set on the document, a browser falls back to *heuristic* freshness — roughly a tenth of the file's age — so on a panel that has been up a month it will not ask the server about that page for about three days. What it keeps serving in that window names the previous hashed bundle under `/assets/`, which is still on disk, because the frontend untars over the directory rather than replacing it. So nothing 404s and nothing white-screens: the operator updates, is told it worked, and goes on running the old frontend against the new backend. A stale panel that looks healthy is harder to notice than a broken one. The install template gained the fix immediately, and new installs have had it ever since, because the installer tracks `main`. **Existing boxes had no path to it at all** — `setup.sh` writes the vhost only at install time and the updater never re-runs it. `update.sh` now migrates the panel vhost in place, the same way it has handled previous nginx changes. The headers it repeats are copied from the vhost being migrated rather than from the script, because a location block's `add_header` directives *replace* the server block's set instead of merging with it: writing today's list would have stripped the CSP off the one response that carries it, and would have quietly given an older install a different policy on a single response. A vhost with no server-level header is skipped and logged rather than guessed at. Verified by serving the migrated config with a real nginx: `/index.html`, the SPA root, and a deep client route all return `Cache-Control: no-cache` with all seven security headers intact, and `/assets/` stays `public, immutable`. The migration is idempotent. ## [2.47.0] - 2026-07-28 Found by driving v2.46.0 on throwaway servers rather than reading it: a fresh install, an older release upgraded in place, and a two-server fleet. 2.46.0 gave the panel IP allowlist real range matching and a control — this release makes it apply to every door that can sign you in, and stops an upgrade from locking your users out. ### Fixed - **Upgrading could lock every non-admin user out of the panel.** Auto-lockdown counts "N suspicious events within M minutes". Before 2.46.0, ingestion of those events sat under `auto_heal_enabled`, which is seeded **off**, so on a stock install the agent wrote them to `/var/lib/dockpanel/suspicious-events.jsonl` and nothing ever drained the file. 2.46.0 correctly detached security monitoring from that switch — and the first tick after upgrading then read the entire accumulated backlog and stamped every line with the time it was *read* rather than the time it *happened*. However long the queue took to build, it counted as one instant. Any box that had ever seen five suspicious commands locked down immediately, for 24 hours. Driven on an upgraded box: sixteen events generated over about three minutes of real use were recorded 105 milliseconds apart, lockdown engaged, and a non-admin got `503 System is in lockdown mode`. The agent has always written a per-event timestamp; the ingest now honours it, so an old backlog lands outside the window and a genuine burst still trips the rule. **If you upgraded to 2.46.0 and were locked out, this is why** — the guide's recovery SQL clears it, and upgrading to 2.47.0 stops it recurring. - **The panel IP allowlist only guarded the password login.** `allowed_panel_ips` gates access to the panel, but the check lived inline in one handler, and three other endpoints mint exactly the same session cookie: passkey authentication, the OAuth callback, and the second step of a 2FA login. An operator who restricted the panel to their office range still had those answering from anywhere. Verified from an excluded address on a real box: the password door returned `403`, while `passkey/auth/begin` returned `200` and a usable WebAuthn challenge. All four doors now share one implementation, and a regression pin discovers session-minting handlers from the source and fails if one of them lacks the check — so a fifth door cannot be added without it. - **Lockdown did not apply to the OAuth login path.** Lockdown holds non-admins out until it expires or an admin clears it. The password and passkey paths enforced it; the OAuth callback had no such check, so with SSO configured a lockdown did not cover that route. It now enforces it, with the same admin escape hatch as the other doors. ### Added - **The session-recording toggle now says which servers it does not control.** Recording is one setting, but each server's agent enforces it: the decision travels as a signed claim in the terminal ticket, and an agent older than 2.46.0 does not read that claim and keeps recording. Switching recording off therefore made a fleet-wide promise that was false for any member still behind — the same shape as the toggle that reported success while changing nothing. Confirmed on a two-server fleet: with the panel asking for `record=false`, the 2.46.0 agent wrote no `.cast` file and a 2.45.1 agent on the same box wrote one anyway. **Settings → Security** now names any server whose agent predates the gate, so an operator can see the gap instead of being told it does not exist. ## [2.46.0] - 2026-07-28 Every operator setting is a claim made in three places — the code that reads it, the API that decides it may be written, and the control that renders it. Nothing made those three agree, and they had drifted in both directions. ### Fixed - **Terminal session recording ignored its own toggle.** The switch saved, reported "Session recording disabled", and changed nothing: the agent opened a `.cast` file for every session unconditionally and the panel had no way to tell it otherwise. The decision now travels as a signed claim inside the terminal ticket — deliberately not a query parameter, since the browser holds that ticket and connects to the agent directly, and a parameter would let a user switch off the recording of their own session. Agents older than 2.46.0 ignore the claim and keep recording; update the agent for the toggle to take effect on a fleet member. - **Canary file monitoring ignored its own toggle** in the same way, and the monitoring itself was gated by the wrong switch. Suspicious-event ingestion, auto-lockdown expiry and canary checks all sat under `auto_heal_enabled`, so turning off auto-healing silently turned off security monitoring nobody had asked to stop. They now run on their own. **Upgrade impact, worth reading before you update.** On a box with auto-healing **off**, those three were dormant — and this release wakes them. Suspicious events start being counted, so **auto-lockdown can now fire where it never could before**, and lockdown blocks non-admin access until it expires (24h) or an admin clears it. That is the intended behaviour — a security control should not be switched off by an unrelated setting — but it is a change in what your box does. If you do not want it, set the auto-lockdown threshold deliberately in **Settings → Security Hardening** rather than leaving auto-healing off as an accidental kill switch. Verified on our own demo, where auto-healing was off: the monitoring came up with the upgrade and locked down on the first burst of suspicious events. - **The panel IP allowlist could not be set, and rejected the ranges it documented.** `allowed_panel_ips` gates login, and the guide told operators to set it in Settings — where no control existed and the API answered `400 Unknown setting`. It also compared the client address to each entry as a string, so the CIDR ranges the same guide promised matched nothing and locked the operator out rather than restricting access. It now matches by range for IPv4 and IPv6, validates every entry on save so a typo cannot lock you out, and has a control. It fails closed when the proxy sends no `X-Real-IP`; the guide now says so, and how to recover. - **The site-creation rate limit was disabled by its own seed.** `security_site_rate_limit` is seeded `3` and was read as a boolean, so `3` was not `true`, and the ceiling became 999 per hour. An install without the row limited to 3; an install with it did not limit at all. It is read as the count it always was, `0` turns it off, and it has a control. - **Exporting a config and importing it dropped your security posture.** The writable-key whitelist was spelled out twice and the copies had drifted: `import_config`'s was missing the registration gates and every `security_*` toggle, under a comment claiming it used the same list as `update`. One list now serves both. ### Added - Controls for settings that were previously only reachable by editing the database: the panel IP allowlist, the server-terminal kill switch, the auto-lockdown window (the threshold's other half — the rule reads "5 events in 10 minutes" and only the 5 was adjustable), and the site-creation rate limit. - `tests/settings-controls-pin-e2e.sh` — 17 assertions that **discover** their subjects rather than naming them: the whitelist is parsed out of the source, configurable knobs are found by the helper that reads them, and every arm fails when discovery finds fewer subjects than are known to exist. It fails if a writable key is read by nothing, if a knob is unsettable, if a toggle's comparison disagrees with the server's own default, or if a numerically-seeded key is read as a boolean. ### Removed - Three controls that fronted features which were never built: a timezone selector claiming to "affect displayed timestamps throughout the panel", an email footer "appended to notification emails", and an events webhook that "receives POST for site.create, app.deploy, security.scan". All three saved successfully and none was read by any code. They will return with the behaviour they promise. Values already stored are left in place; the two dead seeds no operator could have set (`security_db_backup_retention_days`, `security_backup_chain_enabled`) are dropped by migration. ## [2.45.1] - 2026-07-27 The check added in 2.45.0 found a real defect within minutes of being deployed, and it was a deeper cause than the one it was written for. ### Fixed - **`npm run build` silently un-deployed the agent installer.** `install-agent.sh` lived only in `panel/frontend/dist/`, put there by `setup.sh`, `update.sh` or `deploy-demo.sh`. Vite empties `outDir` on every build — so building the frontend, an ordinary operation with nothing to do with the fleet, **deleted the file from the live panel**, and the command the Add-Server dialog prints returned `200` with SPA fallback HTML until an installer happened to run again. 2.45.0 read this as "one deploy path forgot a step" and fixed that path. The real defect was that the artefact only ever lived in a directory that a routine command wipes. The frontend build now stages it from the single source in `scripts/` on every build — the same thing the marketing site has always done with `website/client/public/install.sh`. The staged copy is a build artefact and is gitignored, so it cannot become a second installer that drifts. The stager **fails** when the source is missing rather than skipping: a silent skip reproduces the defect exactly, and its symptom is an operator piping a web page into `sudo bash`. ## [2.45.0] - 2026-07-27 A release about controls that do not cover what they appear to cover. The previous release found that the documented demo-deploy script had been unable to run for eight releases, because a fix applied by derivation to two installers never reached a third copy that lived outside this repository. That copy is now in it, and the mechanism that could not see it has been rebuilt to discover its subjects instead of naming them. Along the way, a question about whether registration was disabled on one panel turned up a second control with the same shape: visible, believed, and silently bypassed by a sibling. ### Security - **Turning off self-registration did not turn off registration.** The panel has two doors into an account. `POST /api/auth/register` reads `self_registration_enabled` and defaults **closed** — an absent row means disabled — and it has a visible toggle in Settings. The OAuth callback auto-creates a user on first sign-in, read `oauth_auto_create`, and defaulted **open** — an absent row meant *allowed* — and it had **no control anywhere in the panel**, though it was writable through the settings API. Both rows are absent on a fresh install, so the panel's answer to "is registration open?" depended on which door you knocked on. An operator who switched the visible toggle off and later configured a GitHub or Google provider — an ordinary thing to do — had self-registration silently back on through a switch they could not see or reach. An explicit `self_registration_enabled=false` now closes the OAuth path too: off means off, whichever door. This deliberately reads the *explicit* value rather than the effective one, so an install that never touched the row keeps its current behaviour and no working OAuth deployment breaks on upgrade — only operators who actually asked for registration to be off get what they asked for. - **`oauth_auto_create` is no longer a DB-only switch.** It gates account creation and now has a toggle beside Self-Registration. Note that it renders an absent row as **on**, matching the server's default rather than its neighbour's — a control that showed "off" while signups succeeded would be worse than no control at all, because it would end the investigation. ### Added - **`scripts/deploy-demo.sh`.** The third path that installs DockPanel onto a running machine — after `setup.sh` (fresh install) and `update.sh` (upgrade in place) — deploying from published release assets rather than a checkout. It existed outside this repository, which is exactly why it rotted: no pin, no CI job and no documentation check can see a file that is not in the tree. It takes its panel hostname and repo path from the caller, so it describes the contract rather than one box. - **`tests/registration-gates-pin-e2e.sh`** (14 assertions). Holds both doors above to the same answer: the defaults, the ordering of each gate against the `INSERT` it protects, that every gate has an operator control that actually writes it, and that each control renders the default its own server uses. ### Fixed - **The regression pins named their subjects, so they could not see a new one.** `sandbox-paths-pin-e2e.sh` pinned `setup.sh`, `update.sh`, `install-agent.sh` and `agent-self-update.sh` by name. That is how the third copy of the agent unit went unnoticed for eighteen releases, and how the fourth copy of the `ReadWritePaths` derivation went unnoticed for eight. The suite now **discovers** them: any script that touches `ReadWritePaths` is a copy and joins the assertions by existing, and the same rule covers every script that populates the frontend dist. Discovery that finds fewer subjects than are known to exist fails, rather than reporting a clean run over nothing. - **`{panel}/install-agent.sh` had no check, while its sibling did.** The scheduled live-surfaces check has verified `dockpanel.dev/install.sh` since 2.44.0 — that a missing file answers `200` with SPA fallback HTML rather than `404`, so the advertised command pipes a web page into `bash`. The URL the panel's own Add-Server dialog prints has the identical failure mode and nothing watched it; it was reproduced by hand at the previous release. It is now checked on the same schedule, including that the served installer is byte-identical to `scripts/install-agent.sh`. ## [2.44.1] - 2026-07-27 A dependency-and-provenance release, and the numbers work from the previous commit that had not yet been published under a tag. Every open advisory against this project was assessed rather than merely bumped, and **none of them was ever exploitable here** — which is stated plainly below rather than dressed up as a fix. What was genuinely wrong was quieter: an image that could change major version under a running mail stack without asking, an installer that could silently discard the dependency tree the audit gates had scanned, and a waiver whose reasoning nothing checked. ### Security - **The Roundcube webmail image was `:latest`.** A major Roundcube upgrade could land on a user's mail stack with no warning and no way back — while the panel itself warns users to pin exactly this (`docker_apps.rs` flags `:latest` in any app they deploy). Now `1.7.x-apache`. Deliberately the `.x` line rather than an exact patch: this is a web-facing PHP application and nothing in DockPanel would ever bump a frozen tag, so pinning to `1.7.2` would have quietly stopped its security rebuilds. Verified by digest at the time of pinning — `latest`, `latest-apache` and `1.7.2-apache` were all `sha256:aed1b9b5dc34`, so this changed the guarantee without changing the image. - **Both installers could silently discard the audited dependency tree.** `setup.sh` and `update.sh` ran `npm ci --silent 2>/dev/null || npm install --silent 2>/dev/null` when building the frontend. `npm ci` installs exactly the committed lockfile — the tree the audit gates actually scan; `npm install` re-resolves and can pull versions nothing here has ever seen. Either arm could fail for any reason and say nothing, and if both failed the script died under `set -e` printing no explanation at all. The fallback stays, because a box whose lockfile has drifted should still update. It is now loud: the reason `npm ci` failed is shown, and the fallback announces that the tree is being re-resolved and may differ from the audited lockfile. - **`body-parser` upgraded 2.2.2 → 2.3.0** (GHSA-v422-hmwv-36x6, low), with the floor set in `package.json` and not only in the lockfile. *Was it exploitable? No.* The advisory needs an invalid `limit` value to be passed, at which point size enforcement is silently disabled. The only body parser in `website/server` is `express.json()` with no options at all, so there was never a `limit` for us to get wrong. - **The `react-router` advisory (GHSA-qwww-vcr4-c8h2, high) was re-derived from scratch rather than carried, and the waiver holds.** It requires RSC mode with server actions. Both frontends are Vite SPAs mounting `BrowserRouter`, with no `@react-router/*` server package, no `react-router.config.*`, no `createRequestHandler` and no server actions. Upstream's first patched version is 8.3.0 — a major — so clearing the alert means migrating both frontends, not bumping them, and npm's only in-range offer remains a downgrade to 7.11.0. What changed is that the waiver is no longer trusted on its own word. Its upstream half was already re-checked daily; the half that is a fact about *us* — that no frontend here runs a server runtime — is now pinned by `ssl-correctness-pin-e2e.sh`, which fails if any frontend gains the machinery that would make the advisory apply. The frontends are discovered from the manifests that depend on `react-router-dom`, so a third one joins the check by existing rather than by being remembered. - `spin` moved off a yanked release (0.9.8 → 0.9.9) in the backend lockfile, clearing the last `cargo audit` warning there. *Was it exploitable? No — it was never even compiled.* `cargo tree -i spin` returns nothing and no artifact is produced; it is a phantom lockfile entry like the `rsa` one already documented in `.cargo/audit.toml`. The warning was real, the exposure was not. `rustls-pemfile` (RUSTSEC-2025-0134, unmaintained) in the agent is the one finding that genuinely stands: it *is* linked and reachable, parsing certificates in `tls.rs`. But "unmaintained" is not a vulnerability, 2.2.0 is the newest release in existence, and the other consumer is `axum-server`. It stays an accepted, written-down warning rather than a silent one. ### Fixed - **The panel's memory footprint had been published as `~19 MB` since April. It is `~49 MB`.** The figure appeared on the README masthead, the README comparison table, the README architecture note, `COMPARISON.md`, `docs/getting-started.md` and the marketing site. It was measured once, on one box, and then copied. A real reading by cgroup accounting gives ~14 MB for the API and ~35 MB for the agent; with the bundled PostgreSQL the stack is ~109 MB, not the published ~85 MB. The comparison against cPanel and CloudPanel was also unfair in our favour: it set DockPanel's services, without a database, against competitors' figures that include theirs. Stack against stack it is about 7x lighter rather than 10x — still the strongest claim on the page, and now one that survives being checked. - Binaries were published as `~41 MB` total; the v2.44.0 release assets are 22 MB (API), 21 MB (agent) and 1.7 MB (CLI) — 45 MB. The install animation on the front page announced a 41 MB download for a 22 MB binary. - `776 API endpoints` was a number nothing derived, and its own decomposition (`496 backend + 280 agent`) matched the source on neither side. It is 809 routes — 527 and 282 — and it is now counted rather than remembered. - `454 E2E tests`, `89 DB migrations` and `11 background services` were likewise stale or undefined. They are 309 regression assertions across eleven suites, 97 migrations, and 15 supervised background services. - Two entries of the same FAQ list on the front page gave different answers for the same measurement, three lines apart. ### Added - **A measurement register** (`FEATURES.md` → "Verified Metrics"). Every number this project publishes about itself is written down once, with its derivation: computed from source, read from the published release, or measured on a real box. `docs-claims-pin-e2e.sh` now fails the build when a surface states a figure the register does not, when a derived figure no longer matches source, or when a corrected figure reappears anywhere. - **A scheduled check** (`live-surfaces-check.sh`, `live-surfaces.yml`, daily). Documentation rot sorts into classes that need different mechanisms, and the one that drifts with *time* rather than commits had none — no push-triggered job can notice an expiring certificate, an install one-liner that started serving HTML, or a site still publishing the bundle it was built from three weeks ago. It runs from a GitHub runner rather than the origin, so the CDN in front of the sites is inside the test rather than behind it. - Claims that no machine can verify — competitor pricing and memory, whether the screenshots still resemble the product — now carry a last-verified date and a budget, and expire. The failure does not assert the claim is wrong; it reports that nobody has looked in long enough that we no longer know. ## [2.44.0] - 2026-07-27 ### Security - **Every server added through "Add Server" has been running the agent with no sandbox at all, under a comment saying otherwise.** `install-agent.sh` — the only documented way to add a remote server, and so the installer that built every fleet in existence — hand-wrote its own copy of the systemd unit. That copy set all four of systemd's headline protection switches to off, declared no writable-path list whatsoever, and omitted eight further hardening directives the real unit sets, beneath the line "Create systemd service (matching local agent hardening)". It matched nothing. The panel box and every box upgraded through `update.sh` were correctly confined; the remote servers those panels manage were not, from v2.28.0 to here. There is now one unit. `panel/agent/dockpanel-agent.service` is compiled into the agent (`--print-unit`) exactly as `agent-self-update.sh` already was, so it cannot drift from the binary that runs under it, and all three installers obtain it from the same place instead of keeping copies. The unit gained an optional `EnvironmentFile=-`, which is what lets one file serve both a panel box and an agent-only box. - **And the fix reaches servers that already exist**, which is the part that would otherwise have made it worthless: a corrected installer only ever changes what the *next* server gets, and the agent self-update had never touched the unit. The agent now reconciles its own unit at startup, and the self-update installs the new one before the restart that applies it — with the previous unit restored if the agent does not come back. Directories named by `ReadWritePaths` are derived and created before either swap, because an unprefixed entry that does not exist makes the unit unstartable and an agent-only box has no `/etc/nginx`. Verified before and after on one Rocky 9 box (SELinux Enforcing) registered to a real panel over a real certificate. Before: `ReadWritePaths=` empty and every protection off. After the binary landed and the agent restarted: `/usr` and `/root` read-only inside its namespace, `/etc/nginx` writable, `/tmp` private, `NoNewPrivs` set on the process — and the server still `online`. ### Fixed - **Adding a remote server has never worked on Rocky, AlmaLinux or CentOS**, and it failed without saying anything. `install-agent.sh` installed Docker with `get.docker.com`, which points those distributions at `download.docker.com/linux/rocky` — a path that carries no `docker-ce`, so the run ended at `Unable to find a match`. v2.37.0 fixed exactly this in the panel installer and the fix never reached the agent installer, which is the same copy-drift the change above is about. Every stream in that step was redirected to `/dev/null`, so what a user saw was the script stopping at `[2/7] Installing dependencies...` with no error at all. The RHEL clones now use the same repository the panel installer uses, and a Docker install that did not work now says so and stops. - **An agent binary older than the flag it is asked for no longer hangs the installer.** Until now the agent ignored its arguments entirely, so asking an older one for `--print-unit` started the *daemon* — it bound the agent socket and never returned, leaving the install stuck at `[7/7]` for ever. Both callers now bound the call, and the agent rejects an unrecognised option instead of starting up. (Running it with no arguments still starts the agent, as before.) ## [2.43.0] - 2026-07-27 ### Fixed - **Webmail showed an empty inbox on every install that already had it, and the one mechanism meant to repair that wrote the broken configuration itself.** The `/webmail/` nginx fragment is written only when you click Install. v2.36.0 fixed a real defect in its contents — without a re-declared header set the location inherits the panel's `frame-ancestors 'none'`, Roundcube's content frame is refused, and the resulting `SecurityError` aborts `list_mailbox` before the message list is ever requested — but that fix reached nobody who had already installed webmail. Worse, `update.sh` carried a hand-copied mirror of the fragment, frozen at the v2.10.1 shape with no header set, and its heal fired only when `sub_filter` was missing: a box with an older fragment was *healed into* the broken shape, and a box with any v2.10.1–v2.35.x fragment failed the guard and was left untouched indefinitely. Both halves of the only recovery path therefore produced or preserved an empty inbox, on a mailbox holding real mail. The agent now owns the fragment outright and reconciles it against the current template at startup, so an upgrade is all it takes. The shell mirror is deleted rather than corrected — one writer is the fix. Verified before and after on one box: 0 message rows with the frame blocked, then 3 rows and a message opened and read, from an agent restart alone. - **The spam filter could not be installed on the RHEL family at all.** `rspamd` is not in EPEL, so on a stock Rocky 9 with EPEL *and* CRB enabled the panel's Install button returned `Unable to find a match: rspamd`. DockPanel now adds upstream's rpm repository — on the RPM family only, since Debian and Ubuntu package rspamd themselves and that path already worked. - **And once installed, Postfix never consulted it — on every family.** Rspamd was wired into Postfix by replacing the literal `smtpd_milters = unix:opendkim/opendkim.sock`, a value v2.41.0 stopped writing when it moved OpenDKIM's milter to a loopback port. Replacing an absent string returns the original unchanged, so `main.cf` was rewritten byte-identical and the installer reported success; with `milter_default_action = accept`, mail simply flowed unfiltered and nothing was logged. The milter list is now derived from what the file actually contains rather than matched as a literal, and a `main.cf` with no milter list is reported as an error instead of being written back unchanged. Verified on the wire: a GTUBE test message is now rejected by the milter at end-of-message. ## [2.42.0] - 2026-07-27 ### Fixed - **The dashboard CPU gauge reported the caller's timing, not the machine's load.** It read 99% on a box `top` showed 94.6% idle, while the 24-hour chart built from the *same* endpoint read 9.7%. Nothing was wrong with the box. CPU percentage is a delta over a window, and `/system/info` refreshed CPU inside the request handler — so the window was "time since whichever unrelated caller last hit this endpoint". Five callers share it (the metrics collector every 30s, the dashboard WebSocket loop every ~5s, the dashboard REST tick, the backup scheduler, the Docker-apps page), so each one silently corrupted the others' measurements. Compounding it, the handler walked every process in `/proc` on each call — 70ms of CPU with 583 processes — purely to obtain a *count*, and that walk landed inside the next caller's window, making the endpoint the largest consumer in its own measurement. Below sysinfo's 200ms minimum a refresh is *skipped* rather than rejected, so closely-spaced callers received a stale value with no error anywhere. Measured on a single-core box running the published v2.41.0: the same idle machine reported 21.9% at a 0.25s request gap and 1.8% at a 10s gap — a twelvefold swing with no change in load — and reading 0.3s after a six-second burst ended returned 95.5% while the box was idle. That last case is what a user meets: the install saturates the box, the dashboard opens, and the first reading covers the install rather than the present. Fixed structurally. A dedicated sampler owns the window: its own CPU-only `System`, a fixed two-second cadence, publishing to an atomic that handlers read for free. The number no longer depends on who asks or how often. On the same box the reported value now stays within a few points of `/proc/stat` at every request spacing, and `/system/info` went from 74–134ms to 3–8ms. - **Fleet check-ins reported a number made mostly of their own `/proc` walk.** `phone_home` built a `System::new_all()`, called `refresh_all()` immediately after, and read CPU usage off the pair. On an idle 12-core box whose true usage was 4.5%, consecutive runs returned 4.5% to 22.1%. That value is stored as `servers.cpu_usage`, which the Servers page displays and the alert engine compares against CPU thresholds — so it could both raise and withhold alerts on fiction. It now reports the sampled value. - **A directory the agent must create could be silently unwritable, and one already was on upgraded boxes.** In the agent unit's `ReadWritePaths`, a `-` prefix means "bind if it exists": when the path is absent systemd skips it *silently*, the unit starts and reports success, and every write beneath it fails with `Read-only file system` until the next restart — creating the directory afterwards does not rescue a running service, because the mount namespace is fixed at start. The installers pre-create these paths for that reason, and the list was hand-copied into both `setup.sh` and `update.sh`. It had drifted: `/var/spool/cron` reached the unit and `setup.sh` in v2.41.0 but never `update.sh`, so a box that *upgraded* without an existing cron spool got a silently unwritable one — defeating, on exactly those installs, the "existing installs recover on upgrade" property the v2.41.0 cron fix was built for. Driven on a fresh box: with the directory absent at agent start `/crons/sync` answers `crontab command failed` and writes nothing; with it present the same call syncs. Both scripts now derive the list from the unit itself, so the mirror cannot drift from it again. - `docs/testing.md`'s summary sentence read "Seven suites, 195 assertions" above an eight-row table summing to 228. Every row in that table was verified against live output by `docs-claims-pin-e2e.sh`; the sentence introducing them was not. It is now. ### Internal - Two new regression pin suites (`cpu-metric-pin-e2e.sh`, `sandbox-paths-pin-e2e.sh`), 32 assertions, each negative-tested to confirm it fails when the defect is reintroduced. - `panel/cli/Cargo.lock` had been pinned at 2.38.0 and `package-lock.json` at 2.26.0 across several releases; both resynced. ## [2.41.0] - 2026-07-26 ### Fixed - **Mail could not work on any RHEL-family box, and the reason it was refused was not the reason it was broken.** From v2.39.0 the panel declined to install the mail server on Rocky/Alma/CentOS with an honest note that nothing past the packages had been driven there. Driving it end to end on two Rocky 9.8 boxes found five separate defects, and disproved the refusal's own premise in both directions: the packages did **not** install (EPEL's `opendkim` needs `libmilter` and `libmemcached`, both in the **CRB** repository, which EPEL requires and `setup.sh` never enabled), and the Debian failure mode the refusal warned about — a package manager's postinst starting the daemons so "installed and running" is true for free — cannot occur on RHEL at all, which does not start services on install. What actually broke, none of it named by the refusal: - **Postfix listened only on loopback.** `inet_interfaces` was never set anywhere in the tree; Debian's debconf writes `all`, the RHEL package ships `localhost`. No mail could arrive from another host, with the firewall correctly opened and the installer reporting success. - **OpenDKIM never started, so nothing was ever signed.** The config wrote `TrustAnchorFile /usr/share/dns/root.key`, a Debian path; OpenDKIM treats a missing anchor as fatal (`status=78/CONFIG`). - **The `-f` flag was correct for Debian and wrong here.** v2.36.0 removed it because Debian's packaged unit is `Type=forking`; EPEL's is `Type=simple`, where the daemon backgrounds itself, systemd reaps the parent and logs `Deactivated successfully` while the unit goes inactive — a failure that does not register as failed. The drop-in now pins `Type` as well as `ExecStart`, so the distro's choice cannot decide the flag. - **The milter socket lived in Postfix's chroot**, an arrangement that exists only because Debian chroots `smtpd`. RHEL does not, and SELinux forbids it: `dkim_milter_t` is denied `search` on `postfix_spool_t`. The milter is now a loopback port, which is correct on both families and needs no shared group, socket directory or ownership dance. - **Every delivered message was silently discarded.** `/var/vmail` is created by the installer, so it inherited `var_t`, which `dovecot_t` may not write — LMTP failed `mkdir` with "Permission denied" on a directory owned by `vmail:vmail` and mode 0755, and every message was deferred forever while both services were active. It is now labelled `mail_spool_t`. - **No mailbox could be opened.** Mailbox passwords are hashed `{ARGON2ID}`, and Rocky 9.8 ships Dovecot 2.3.16 built *without* Argon2 — the scheme is a build option, not a version, though the code's own comment cited ">= 2.3.11". Every login failed `Unknown scheme ARGON2ID` while the panel reported the account created successfully. The panel now asks the agent which schemes its Dovecot actually supports and picks Argon2id where present, bcrypt where not, so Debian and Ubuntu are unchanged. Verified on the wire: a message travelled between two real domains on two Rocky 9.8 boxes and arrived `dkim=pass`, verified by the receiving box's own OpenDKIM against a key published in real DNS, and the mailbox opened over IMAP on the box's real Let's Encrypt certificate. Re-verified on Debian, where the hash stays `{ARGON2ID}` and OpenDKIM still starts under its forking unit. - **Cron jobs could not be created on any install once a WordPress site existed.** The panel auto-creates a WordPress cron whose command contains `> /dev/null 2>&1`, INSERTed straight into the database — but `> /dev/` is on the agent's own blocked-pattern list, and the sync endpoint validated every row and rejected the whole batch on the first bad one. From the moment a WordPress site was created, adding, editing or deleting *any* cron failed with "Command contains disallowed characters or patterns" and nothing reached the crontab. The writer no longer emits the redirect, and the reader now skips an unsafe row (reporting it) instead of failing the entire sync, so installs that already carry the row recover on upgrade. - **`/var/spool/cron` was missing from the agent's `ReadWritePaths`** while `setup.sh` pre-created it, so `crontab` could not write its temp file (`mkstemp: Read-only file system`) and cron writes returned 500. - Mail uninstall and the Rspamd installer still shelled out to `apt-get` directly, so both failed on RPM with "Failed to find executable apt-get"; both now use the package abstraction, and the Redis unit name is translated (`redis-server` on Debian, `redis` on the RHEL family). - `mail_status` reported `installed: true, running: true` while OpenDKIM was in a restart loop, because OpenDKIM was excluded from the summary verdict. - The mail log viewer read `/var/log/mail.log` unconditionally, so it showed zero sent and zero received on RHEL, which writes `/var/log/maillog`. ### Changed - README and marketing screenshots retaken on the `terminal` theme. ## [2.40.0] - 2026-07-26 ### Fixed - **No package operation could run on any SELinux system, and it never could.** Installing Redis, Node.js, PowerDNS, the WAF, Cloudflare Tunnel, Composer, Fail2Ban or a PHP extension from the panel failed on every RHEL-family box with `Failed to start transient service unit: Connection reset by peer`. This had been true since the agent's sandbox was introduced, and the cause was one flag: the agent escaped its `ProtectSystem=strict` sandbox with `systemd-run --pipe`, which passes stdin/stdout/stderr **as file descriptors over D-Bus**. On the RHEL family the system bus is `dbus-broker`, and SELinux checks the receiver's access to a passed object — receiving a writable pipe labelled `unconfined_service_t`, the label every systemd service's pipes carry, is denied. The broker drops the connection, and the rule is `dontaudit`ed, so nothing whatsoever is logged. The same command works from a shell because a shell's pipes are labelled `unconfined_t`. The escape hatch no longer passes descriptors at all: systemd is asked to open the capture files itself (`-p StandardOutput=file:…`), which it may do, while `--wait` still propagates the inner command's exit status. The change applies on every distribution rather than only where it broke, so the path Debian and Ubuntu exercise is the same one. Verified on Rocky 9.8, before and after on one box: Redis 6.2.22, Composer 2.10.2, Fail2Ban 1.1.0, Node.js 22.23.1, PowerDNS 5.0.6, ModSecurity 1.0.4, cloudflared 2026.7.3 and `php-bcmath` all installed from the panel, on the machine that had refused every one of them minutes earlier. Re-verified on Debian 12 so the working family stayed working. UFW still refuses on a box already running firewalld, and the mail server still refuses on RPM — both deliberate, both stated. ## [2.39.0] - 2026-07-26 ### Fixed - **Automatic updates left the agent unable to start on SELinux systems.** A binary moved into `/usr/local/bin` keeps the label it had at its source — a rename within one filesystem preserves it, it does not adopt the destination's. `agent-self-update.sh` stages the download under `/var/lib/dockpanel` and `update.sh` moves the release binaries in from `/tmp`, so on Rocky, AlmaLinux, CentOS Stream and Fedora the new binary arrived labelled `var_lib_t` or `user_tmp_t` instead of `bin_t`, and systemd then refused to execute it (`status=203/EXEC`, "Permission denied") — while the update reported success. Because the agent's own updater runs on a six-hourly timer, every RHEL-family install would have lost its agent at the first automatic update after installation. Both paths now restore the security context after the swap. - **A fresh install on the RHEL family got end-of-life PHP 8.0.** `dnf install php-fpm` resolves to the non-modular base package unless a module stream is selected first, so the installer produced PHP 8.0.30 — older than every stream those distros offer (8.1, 8.2, 8.3) and unsupported since November 2023 — and printed "PHP 8.0 (FPM)" as if that were the intended outcome. It also made the panel's own PHP installer unreachable, since that checks whether PHP is present, finds 8.0, and reports "already installed". The installer now selects the newest stream the system offers before installing. - **The Services and PHP pages misreported PHP on the RHEL family.** The per-version package query collapses onto the single unversioned `php-fpm` package there, so every offered version read as installed, while the running-check and socket path were both Debian-shaped, so none read as running. The version is now read from the package database. ### Changed - **Package operations dispatch on the package manager the system actually has.** `services/pkg.rs` grew from a query layer into an install layer: install/remove routed through the real manager, per-family repository setup for NodeSource and Cloudflare, PHP module-stream selection, and a systemd **unit**-name map alongside the package-name map — a package name and a unit name are different strings that merely coincide on Debian, and translating only the first installs the right package and then enables a unit that is not there. The optional-service installers, the mail installer and the PHP version manager all route through it. - **The panel now states plainly that it cannot install packages on RHEL-family systems, instead of failing with an internal error.** The agent performs privileged package work by asking systemd for a transient unit; on the RHEL family that request is refused when it comes from inside the agent's service (`Failed to start transient service unit: Connection reset by peer`). This is a long-standing limitation rather than a new one — Composer installation, unchanged in this release, fails the same way — and it is not caused by the agent's sandbox, which was ruled out by reproducing the failure with every restriction disabled. Until it is resolved, these endpoints report the limitation and point at the system package manager rather than surfacing a D-Bus error. **DockPanel does not claim panel-driven service installation on RHEL-family systems in this release.** - **UFW installation is refused on systems running firewalld**, naming the reason. UFW is installable there, which is precisely the hazard: it would create a second rule set that nothing consults — the failure mode that made v2.37.0 installs unreachable. Ports are opened through whichever firewall is actually enforcing. - **Mail server installation is refused on RHEL-family systems** until the configuration half has been verified there. The packages resolve, but a mail stack whose daemons are running and whose configuration is wrong reports itself healthy and delivers nothing. ## [2.38.0] - 2026-07-26 ### Fixed - **On the RHEL family the panel installed successfully and could not be reached.** v2.37.0 made the installer complete on Rocky, AlmaLinux, CentOS Stream and Fedora, and verified it with `/api/health` — measured on `127.0.0.1:3080`, which bypasses nginx. Driving the same install from a browser found it unusable, for two independent reasons: - **Two firewalls, and the installer configured the one that was not enforcing.** These distros boot with **firewalld** running and only SSH allowed. `setup.sh` installed UFW alongside it and opened 80/443 in UFW, while firewalld went on dropping them. The panel was unreachable, and Let's Encrypt could not fetch the ACME challenge, so no certificate was issued either — while the installer printed "installed successfully" and an `https://` URL, and blamed Cloudflare for the SSL failure. The installer now detects the firewall the box is already enforcing with and configures that one, never installing a second; the SSL hint names reachability first; and a failed issuance can no longer be reported as an `https://` panel URL. - **SELinux blocked nginx from reaching the panel API.** With SELinux Enforcing (the default on all four) `httpd_can_network_connect` is off, so every request returned 502 — including from the box itself — with no journal or `ausearch` entry, because the denial is `dontaudit`-ed. The installer now sets the boolean up front. - **`update.sh` repairs both on installs that already exist**, which is the only path in: a box in either state cannot be fixed from a panel it cannot reach. - **The panel misreported the box it was running on.** `is_installed()` shelled out to `dpkg`, and there is no dpkg on an RPM system, so every package read as absent — the Services page offered to install PHP and Fail2Ban while both were installed and running. Four hand-rolled copies of that function existed, which is how it stayed wrong in all of them; package presence now goes through one implementation that dispatches on the real package database and maps the names that differ (`pdns-server`→`pdns`, `redis-server`→`redis`, Debian's three Dovecot packages→`dovecot`). PHP-FPM's *running* check had the same shape in the service manager and now also recognises the single `php-fpm` unit. - **Firewall state was read through `ufw` alone**, so a firewalled RHEL box reported no firewall at all on the Security page, and diagnostics warned "Firewall (ufw) is not active" while naming a tool the operator does not have. Both now dispatch on the running firewall, and the Security page reports firewalld's zone, policy and open services. - **Mail ports were "opened" without checking.** `open_mail_ports()` discarded every result and then logged success unconditionally — false on any box without ufw. It now reports which ports it could not open. Moving the SSH port refuses rather than proceeding if the new port cannot be opened, instead of locking the operator out. ### Changed - Optional-service installers that are still Debian/Ubuntu-only (Redis, Node.js, PowerDNS, mail, WAF, Cloudflare Tunnel) now refuse on other distributions with a stated reason and a remedy, instead of failing with `Failed to find executable apt-get`. The limitation is documented in the README and the getting-started requirements. ## [2.37.0] - 2026-07-26 ### Fixed - **DockPanel could not install on any RPM-family distro.** The README, the docs and the website all claimed CentOS 9+, Rocky 9+, Fedora 39+ and Amazon Linux 2023, and the release smoke-test matrix contained only Debian and Ubuntu images. Driving all four on real servers found every one of them failing, in three different places: - **Rocky and AlmaLinux died installing Docker.** `get.docker.com` sends each distro to its own repository path, and `download.docker.com/linux/rocky/9/` publishes `containerd.io` and the plugins but no `docker-ce` — so the install aborted at step 3 of 15 with `Unable to find a match: docker-ce`. AlmaLinux is not in that script's distro list at all. Both now get an explicit repository pointing at the `centos` path, whose packages are plain `el$releasever` builds. - **CentOS Stream died configuring Nginx.** The step that comments out RHEL's default server block used a `sed` range terminating at the first `}`, which inside a server block belongs to a nested `location` — half the block stayed live at `http` level and `nginx -t` failed with `"location" directive is not allowed here`. It now counts braces to find the block's real end. - **Fedora died starting the agent.** The unit listed `/etc/apt` in `ReadWritePaths`; systemd fails the entire mount namespace when any entry is missing, so on an RPM box the agent could not start at all. Distro-specific paths now carry systemd's `-` prefix, so a missing directory can no longer make the agent unstartable — the class fix, not just this path. ### Changed - **Amazon Linux 2023 removed from the support claim, AlmaLinux 9+ added.** Docker's install script has no Amazon Linux branch and no image is available to verify a fix against, so the claim was withdrawn rather than left standing on nothing. AlmaLinux — which the installer already greeted by name while being unable to install on it — is now claimed, tested and verified. - The release smoke-test matrix now covers Rocky 9, AlmaLinux 9, CentOS Stream 9, Fedora 39 and 43 and Amazon Linux 2023 alongside the apt distros, with a per-family package-manager step. ### Added - `tests/rpm-install-pin-e2e.sh` (7 assertions) pins all three fixes, including a check that every sandbox path is either optional or created before the unit starts — so the next `/etc/apt` cannot happen. - `tests/docs-claims-pin-e2e.sh` now fails the build when a distro family is named on any published surface and no image in the smoke matrix tests it. It locates support claims by pattern across all three surfaces, which turned up a fifth claim site nobody had been maintaining. ## [2.36.0] - 2026-07-26 ### Fixed - **The mail server installer had never completed, on any install.** It aborted at `Failed to write opendkim.conf: Read-only file system`: the agent runs `ProtectSystem=strict`, and `/etc/opendkim.conf` is a bare file in `/etc` — the one mail path the unit's `ReadWritePaths` does not cover. Everything after that line never ran. OpenDKIM's config now lives at `/etc/dockpanel/opendkim.conf`, inside the permitted paths, with a systemd drop-in pointing the daemon at it. The sandbox is not widened. - **No outgoing message had ever been DKIM-signed.** OpenDKIM's `KeyTable` and `SigningTable` were written empty and nothing ever populated them, so no domain was bound to its key — while the generated key was published in DNS and verified green by the panel's own DNS check. Adding or removing a mail domain now rebuilds both tables from the keys on disk and reloads OpenDKIM. Verified on the wire between two real domains: `dkim=pass`, and the receiving spam filter's score on the same message fell from 9.74/15 to 1.59/15. - **The mail ports were never opened in the firewall.** Postfix and Dovecot were started behind a UFW that allowed only 80, 443 and the panel, so no mail could arrive. The installer now opens 25, 587, 465, 143, 993, 110 and 995. - **Postfix announced itself with a short hostname**, costing six spam points before any content was examined. `myhostname` is now set from the panel domain, and `mydestination` is narrowed to `localhost` so that a hosted mail domain is never mistaken for a local one and bounced as "unknown user". - **Re-running the installer erased every hosted domain, mailbox and password** by truncating the Postfix maps and the Dovecot users file. They are now created only when absent. Re-running also appended a duplicate `submission` service each time, because the guard matched the commented-out stock entry. - **Roundcube could not log in at all.** Dovecot required TLS but was never given the Let's Encrypt certificate the box already held, so it served a self-signed one and every IMAP client refused it with "unknown ca". Dovecot and Postfix now use the panel's certificate when there is one. - **The panel's own `frame-ancestors 'none'` applied to the webmail it installs.** The `/webmail/` location now allows same-origin framing, which Roundcube's skin requires. - **`mail_status` reported a failed installation as healthy**, because it asked only whether the packages were present and the services up — both of which `apt` provides on its own. It now also reports whether the configuration was actually written. ### Known issue - The Roundcube message list can still render empty even though the mailbox has mail: a frame is navigated to the panel root, whose stricter policy refuses it. Login, delivery and IMAP are unaffected. Serving webmail on its own hostname avoids it. ## [2.35.0] - 2026-07-26 ### Fixed - **Git deploy could never build on a normal install.** The agent runs `ProtectSystem=strict` + `ProtectHome=yes`, and `docker build` creates `$HOME/.docker` before it will run — so with `HOME=/root` mounted read-only, every Dockerfile-based deploy aborted at `mkdir /root/.docker: read-only file system`. The repository was cloned, the build never ran once, on any install. The docker CLI is now pointed at `DOCKER_CONFIG=/var/lib/dockpanel/docker`, inside the unit's `ReadWritePaths`. Set in the shared `safe_cmd` helpers rather than at a call site: `env_clear()` means a unit-level `Environment=` never reaches the child, and ~77 docker invocations share those helpers. The sandbox itself is unchanged — `/root` and `/usr/local/bin` stay read-only. - **The Dockerfile-less fallback could not rescue it either.** nixpacks installed itself into `/usr/local/bin` (read-only under the same sandbox) and cached into `/var/cache/dockpanel` (never in `ReadWritePaths`). Both now live under `/var/lib/dockpanel`, matching how the image scanner already handles this. A previously downloaded copy is also found again, instead of being re-fetched on every agent restart because it sits off the agent's `PATH`. - **`update.sh`'s agent health check could never pass.** It probed the panel's `/api/system/info` with no credentials; that endpoint is authenticated, so it answered 401, `curl -sf` read that as failure, and every update on every install printed "Agent connectivity check failed" whether the agent was healthy or dead. It now asks the agent's own `/health` over its unix socket, which is auth-exempt by design, and falls back to the legacy `/var/run` socket path on older boxes. - **`DOCKPANEL_VERSION` was read by the installer and ignored by its only consumer.** `install.sh` clones the requested ref, but `setup.sh` always downloaded `releases/latest` — so `DOCKPANEL_VERSION=v2.31.2` produced a v2.31.2 tree running the newest binaries and reported the newest version on completion. Unit files, nginx templates and `install-agent.sh` are deployed from the tree, so that skew is the same class that once stranded the v2.8.13 → v2.8.14 upgrade. `setup.sh` now honours the pin and names the release it installed. ### Verified - The update path itself, driven end to end on a fresh box for the first time: a published **v2.31.2** install with a live WordPress site upgraded in place to current. Panel, agent, CLI, tree and schema all moved forward (96 → 97 migrations), every row survived, the site and panel kept serving over their real Let's Encrypt certificates, and a backup taken after the upgrade carried its database (`1/1`) while the pre-upgrade row correctly stayed `0/0`. A full restore drill on the upgraded box brought back a deleted WordPress post. Evidence: `dockpanel-update-path-drill-s261.md`. ## [2.34.2] - 2026-07-26 ### Fixed - **Hardening:** the single-file restore now explicitly refuses `.dockpanel-backup`, the archive directory holding a backup's database dumps. It could not reach them in practice — that function prefixes every request with `./` while the payload members carry no prefix — but the protection was an accident of how tar names members rather than a decision, and those dumps are the site's entire content in plaintext being extracted into a publicly-served document root. ## [2.34.1] - 2026-07-26 ### Fixed - **Scheduled and policy-driven site backups still contained no database.** v2.34.0 added databases to the backup the panel button creates, but both automated paths still asked the agent for a files-only archive — so the people most reliant on backups, the ones who set a schedule and stopped thinking about it, kept getting archives that could not restore their content. All three paths now share one resolver, an incomplete scheduled run is logged loudly because nobody is watching it, and the backup row records what the archive holds. - The agent's backup unit tests wrote to `/var/www` and `/var/backups`, so they passed on a provisioned server and failed in CI. They now run entirely under a temp directory; the indirection exists only in test builds. ## [2.34.0] - 2026-07-26 ### Added - **Site backups now contain the site's databases.** A backup was a `tar` of the document root and nothing else, so restoring a WordPress site returned its files and not one post, page, comment or setting — and the panel reported it as a success. Each of the site's databases is now dumped into the same archive, under `.dockpanel-backup/db/`, alongside a manifest describing what is inside. Restoring puts the files back and then loads each dump over the live database. Verified on a fresh server the way the gap was found: a published post was deleted and came back. ### Fixed - **Restoring a MySQL or MariaDB database never worked.** The restore ran `mysql` inside the database container, but DockPanel provisions `mariadb:11`, which no longer ships the mysql-named client symlinks — so every restore failed with "executable file not found" while the *dump* half, which correctly calls `mariadb-dump`, worked fine. Every sibling call site in the codebase already used `mariadb`; this one did not. Affected the Databases page, the backup orchestrator, and scheduled restores. - **A restore that lost your database no longer reports success.** Files restored + database not is now a failure that names what happened, because at that point the site is running restored files against its previous content. A backup that could not dump a database says so at creation time and is marked incomplete in the backups list, so "is my content in here?" is answerable before you need the answer. A database restore that fails with no error output reports the exit status instead of a bare "restore failed:". - **Restoring an older backup warns first.** Archives made before 2.34.0 hold no database. Restoring one onto a site that has one now says so before it starts rather than quietly leaving the content as it was. ### Changed - The `dockpanel backup` CLI states that its archives contain files only, and why: it authenticates to the agent, which has no access to the panel's database records. Restoring an archive that carries database dumps fails from the CLI rather than restoring the files and calling it done. The documented sample output for both commands now matches what the CLI actually prints. ## [2.33.0] - 2026-07-26 ### Fixed - **Nobody could log in to a mailbox.** The Dovecot password file was written `0600 root:root` — the instinctive choice for a file full of password hashes, and one notch too strict: Dovecot's authentication worker drops privileges to the `dovecot` user, so it could not open the file at all. Every IMAP, POP3 and submission login failed with `Temporary authentication failure`, while the panel reported the account as created successfully; the only evidence was a `Permission denied` line in Dovecot's own log. The file is now written `0640` owned by group `dovecot` — still unreadable to every other user on the box, readable by the one process whose job is to read it — with the ownership set before the atomic rename, so the live path is never briefly published with permissions that lock authentication out. Found by creating a mailbox on a fresh box and trying to log in as its owner. - **Auto-sleep stopped containers that were serving users.** Nothing ever recorded a visitor: `last_activity_at` moved only when an administrator woke a container by hand, so "idle" in practice meant "nobody used the *panel*". A container answering a request every sixteen seconds was stopped on the timer, and the next visitor got a 502 with nothing to wake it. The sleeper now asks nginx when the container's domain last served a request and counts that as activity. A domain with no access log reports *unknown* rather than zero, because a silent zero is exactly what would stop a container nothing is known about. - **Enabling auto-sleep on a default install did nothing at all, silently.** The sleeper runs as a step of the auto-healer, which is off by default and configured on a different page, so the switch stored the setting, answered "enabled", and was never acted upon. The setting still saves, but the panel now says plainly when the loop that honours it is switched off, and the control no longer describes an idleness it was not measuring. ### Changed - **A site backup says what it actually contains.** Creating one archives the site directory and nothing else, while the panel's own records know the site has a database. For a CMS that is a fraction of the site: a restore returns the files and not a single post, page or setting — confirmed by restoring one and finding a deleted post still gone. The Backups page now states that backups are files only and points at where databases are backed up, rather than leaving the word "backup" to be read as "my site is safe". Including databases in the archive is a larger change to a destructive restore path and is tracked separately. - **The installer no longer aborts because it could not ask systemd a question.** `systemctl is-active` answers "running", "not running" and "I could not reach the bus" with two exit codes; during install the bus can be momentarily unavailable, and the call then exits non-zero having printed nothing. The installer read that as the service having failed and stopped four steps from the end — observed on a fresh Ubuntu 24.04 box, where the journal excerpt printed directly under "Agent failed to start" showed the agent started and listening. Both service checks now poll for a bounded window and read the answer rather than the exit code: `failed` still gives up at once, and a unit that never starts still fails. (Delivered from `main`, which is what `install.sh` clones.) ## [2.32.0] - 2026-07-25 ### Fixed - **A site whose automatic SSL fails is no longer dead on both schemes.** WordPress was installed at `https://` unconditionally — including when the certificate step had already failed two steps earlier in the same task. WordPress then redirected HTTP to a scheme with no certificate, so a brand-new site answered nothing on either one, while the panel reported it `active` and the final step said it was "served over HTTP". Sites are now installed at the scheme the server can actually serve, and nothing about that claim is a guess. - **A site moves itself to HTTPS the moment it has a certificate.** Nothing in the codebase had ever rewritten a WordPress site address, so a site that started on HTTP stayed there even after issuance succeeded. Enabling SSL for a site now moves its stored `siteurl`/`home` across with it, on every path that can produce a certificate: first provision, retry, DNS-01, wildcard, an uploaded custom certificate, git deploys and Docker apps. It only ever replaces the plain-HTTP form of that vhost's own domain, so a site deliberately pointed elsewhere — a separate front end, a subdirectory install, a `www.` canonical host — is left untouched. - **A certificate that stops renewing raises an alert instead of a log line.** Both renewal loops bailed out with a `tracing::warn!` when no usable ACME contact could be resolved, and the security scanner never alerted on a failed renewal at all. That is precisely the failure that hides best: issuance succeeds thanks to the panel-wide contact fallback, and sixty days later the certificate expires on an unattended server with nobody watching the log. Both loops now raise a critical alert, deduplicated per site so a two-minute loop cannot turn one stuck certificate into a flood. ### Changed - **CI's Security Audit job can fail meaningfully again.** It had failed on every commit for six releases over one advisory that cannot affect this build (a React Router RSC-mode CSRF bypass; both frontends are Vite SPAs with no server runtime), and `npm audit` has no ignore mechanism. A gate that is always red reports a new advisory exactly as well as no gate at all. `scripts/npm-audit-gate.mjs` now waives individually reviewed advisories — printing the reason, and flagging a waiver that no longer matches anything — while still failing the build on everything else. Its behaviour is pinned in both directions. ## [2.31.2] - 2026-07-25 ### Fixed - **Creating your first website no longer takes the control panel offline (domain installs).** `certbot --nginx` writes a wildcard `listen 443 ssl;` onto the panel's vhost, while agent-generated site vhosts bind `:443 ssl`. nginx treats those as separate listen sockets and the explicit-IP one wins every connection to that address, so the panel's `server_name` was never consulted: the first site to receive a certificate became the de-facto server for the panel's own domain, and the panel answered with that site's content and certificate. `setup.sh` now pins the panel's `:443` to the interface IP after certbot runs — the same convention `configure_nginx` already applied to `:80` — and `update.sh` repairs boxes already in that state. Found by driving a real domain install on a fresh box; verified by reproducing the outage before the fix and the survival after it. - **The listen repair restarts nginx instead of reloading it.** A reload cannot move an already-bound `0.0.0.0:443` listener to a specific address — nginx inherits the old socket and the rewrite silently no-ops, leaving an on-disk config that disagrees with what is running. Both scripts now restart and then verify the socket rather than trusting an exit code. - **`update.sh`'s `BASE_URL` repair now actually runs.** Its guard searched for `BASE_URL` as an unanchored substring, which also matches the `DATABASE_URL=` line that `setup.sh` writes first into every `api.env` — so the repair was skipped on every install that has ever existed. It is now anchored, treats a valueless key as unset, and rewrites in place instead of appending a second key. - **Automatic SSL renewal honours the panel-wide ACME contact.** `security_scanner` and `auto_healer` read the site owner's address directly, bypassing the `acme_contact_email` fallback that every issuance path uses. A box whose owner address cannot be a Let's Encrypt contact (a reserved TLD, a typo) issued certificates fine and then silently failed to renew them. Both renewal paths now resolve the contact the same way issuance does. ## [2.31.1] - 2026-07-25 ### Security - **The reserved control-plane domain guard shipped inert in 2.31.0 — upgrade.** 2.31.0 replaced a hardcoded list of vendor domains with the panel's own hostname, derived from `BASE_URL`. That turned out to be the wrong source: `BASE_URL` is only written when the installer was given a `PANEL_DOMAIN`, so on a box whose nginx serves the panel on a real domain it is routinely empty — and an empty value reserved nothing at all. A tenant could then create a site for the panel's own domain, whose vhost takes over that `server_name`, leaving the panel itself answering 404. This is the squat closed in 2.18.0, briefly reopened. The guard now also reserves **the host the request arrived on**, which is by definition the address the panel is being used at, and needs no configuration to be correct. It is only ever used to reserve *more*, so a forged `Host` cannot weaken it. Wired into every domain-introducing path that can see request headers: site create, domain rename, alias add and clone. `BASE_URL` and `RESERVED_DOMAINS` continue to apply. Found by driving the running panel rather than by the test suite — the unit tests passed against the same wrong assumption that produced the bug. ## [2.31.0] - 2026-07-25 The first ten minutes with DockPanel. Everything here is something a new operator meets before they have any way to know what the product expects of them — the account-creation screen, the checklist, the certificate buttons, the dashboard on a box with nothing on it yet. The headline is a security fix: on the install path the README advertises, the panel used to serve the create-your-admin-password form over plain HTTP. ### Security - **The panel no longer asks for a new admin password over an unencrypted connection.** Installing without a domain left nginx serving the panel on `:8443` as plain HTTP, and the very first screen is the one that asks the operator to choose an administrator password. There is no Let's Encrypt certificate to be had without a domain, so the installer now generates a self-signed one and terminates TLS anyway. The browser warns once, which is a thing an operator can reason about; a credential travelling in the clear is not. As a consequence the session cookie now also gains its `Secure` flag on that path, since the flag follows the connection scheme nginx reports. ### Added - Visible password guidance on the account-creation screen. The 8-character minimum was previously enforced only by the browser's native validation, which rejects the form without ever explaining the rule. The text comes from the copy registry, so it is in the generated manual too. - "Point your domain here" in the Getting Started checklist — the prerequisite that gates HTTPS and every guide that follows, and the one step the list never mentioned. ### Changed - Creating the first admin account now signs you in, instead of redirecting to the login screen to retype the password you just chose. - The site SSL section leads with a single **Secure this site** action. Cloudflare DNS validation, wildcard certificates and custom uploads moved behind "Other options", each with a sentence saying when it applies — replacing four flat buttons, one of which was the bare acronym "DNS-01 (CF)". - Restart Nginx / Restart PHP / Reboot no longer appear in the dashboard header on a box with no sites and no apps. The two restarts remain on Diagnostics; Reboot returns whenever the system reports one is actually required. - Reserved control-plane domains are now derived from this install's own panel host (`BASE_URL`), plus anything in a new `RESERVED_DOMAINS` variable. The list used to be the hardcoded triple `dockpanel.dev`, `docs.dockpanel.dev`, `panel.example.com` — our marketing domains and a documentation placeholder, compiled into every customer's build, protecting nothing of theirs. Matching is on the exact host, so a panel at an apex no longer blocks its own subdomains. ### Fixed - The Getting Started checklist counted a step nobody had performed: "Run diagnostics" was hardcoded to complete, so a brand-new box reported 1/5 done with nothing done. - `docs/troubleshooting.md` documented removed behaviour, telling operators that the login cookie's `Secure` flag follows `BASE_URL` and that editing it fixes a failed login. That has not been true since the flag was tied to the connection scheme (#71); the advice did nothing. - `docs/getting-started.md` sent operators with a domain to `https://your-domain.com:8443`, though a domain install moves the panel to the standard HTTPS port. ## [2.30.0] - 2026-07-25 The guidance layer stops being four verticals that share a type and becomes one content system. Its copy now lives in a single registry, the documentation is generated from that registry rather than written beside it, and the passive tier — the line under a field, the text behind an (i) — comes from the same place as the callouts. The reason this was worth doing is in the Fixed section: the manual was already contradicting the product, in a way that broke a real user's mail. ### Added - **One registry of guidance copy** (`services/prerequisites/copy.rs`). Every sentence the guidance layer says — nine checks, thirty-odd outcomes — is data in one greppable file, alongside the prose a format string has nowhere to put: what the check proves, why it matters, and why it blocks rather than warns. A check now decides *which* outcome a situation is; it cannot decide what that outcome says, because the four hand-rolled `PrereqResult` constructors are gone and the only remaining path goes through the registry. - **The documentation is generated from it.** `dockpanel-api --emit-guidance ` writes `docs/guides/prerequisites.md` — the shipped binary emits its own manual. A test regenerates and compares, so changing a sentence without regenerating fails the suite, naming the line and the command to fix it. This is the mechanism, not a convention: a page that is emitted cannot describe a version of the product that no longer exists. - **The passive tier joined the same system.** `components/FieldHelp.tsx` renders field help and a click-to-open (i) from `content/guidance.generated.ts`, emitted from the same registry and type-checked against it, so a typo in a field id is a compile error rather than a silently blank hint. Wired on the create-site form, the Docker deploy dialog, the backup-policy destination and the mail DNS tab. Per the brief, tooltips and callouts are now one content system rendered at different urgencies. - Field help for the CMS admin password, which had none, saying where the generated password is stored — the custody question v2.28.0 fixed but never explained. ### Fixed - **The manual told you to publish a DKIM record that could never verify.** `docs/guides/email.md` documented the selector as `default`; DockPanel has used `dockpanel` for its entire life. Anyone who set mail up from the documentation rather than from the panel got a record at the wrong name, unsigned mail, and — since v2.29.0 — a DNS check correctly reporting it missing while the manual insisted it was right. The same page described the mail host as `mail.example.com` and the SPF policy as `-all`, where the product publishes the apex and `~all`. The records section now sends you to the panel, which knows your server's address and your domain's key, instead of restating values that go stale. - **Backup destination types.** The Backup Manager guide listed `b2` and `gcs` as destination types; the API accepts `s3` and `sftp` and rejects everything else. Backblaze and Google Cloud Storage do work — through their S3-compatible endpoints, as type `s3`, which is now what the table says. The same guide sent you to "Backups > Destinations", which is not where they live. - **Troubleshooting contradicted the DNS check.** It stated a domain must resolve to this server's own IP; the panel treats a domain resolving elsewhere as a warning precisely because issuance through a proxy such as Cloudflare demonstrably works. Resolving to *nothing* is the case that cannot succeed, and that is now what the page says. - **An (i) opened where it could not be read.** The tooltip was clipped by the viewport edge when its trigger sat in a form's right-hand column; it now flips to the right edge when it would overflow. It is also no longer nested inside a `