The follow-up audit patch: a member-level host-execution hole closed, the Volumes page made honest, and the update path made race-proof (r380–r396).
Security
- The static build pack was ungated host command execution for any member (r380).
buildPack: 'static'runs the repository's install and build commands on the panel host (sh -cinengine/builders/staticSite.ts) — the same reach as a PM2 service — but the host-privilege gate (H-3) enumerated only PM2, compose, lifecycle hooks and docker-socket templates. A plain member could create atype: 'docker'service withbuild: { buildPack: 'static', buildCmd: 'curl … | sh' }and the deploy pipeline executed it as the panel user. The gate now coversbuildPackon create, on PATCH (merged with the stored definition, so it cannot be reached one field at a time), on clone, on bundle import (whosebuildPackwas also cast unvalidated), and on every deploy of a stored definition. The build-pack selector marksstaticadmin-only in the UI. - Orchestrator stack names reached filesystem joins unvalidated:
GET /v1/orchestrators/:name/stacks/:stackpassed the segment straight intojoin(STACK_ROOT, name)+readFileSync; names are now shape-checked like every other module (r381).
Fixed
- The dashboard reported every remote-node service as down (r382). The health probe inspected the panel's own Docker daemon; a node-pinned runtime's container lives on the node, so
containerIpanswered null and the health column showed red for services running fine. The panel now reportshealthy: null("on node") for node-pinned runtimes — the same guard Doctor received in r228 — and the "all systems operational" banner counts only explicit failures. The netns probe image is also pinned instead of:latest(the supply-chain rule every other helper image follows). - Volume delete and prune reported success while nothing was deleted (r383).
docker volume rmfails silently for a volume still mounted by an orphaned container, and the route swallowed it: the operator clicked Prune, gotdeleted: N, and the volumes were still on disk — the r351 slug-reuse flow explicitly directs operators there. Both routes now verify the removal landed (volumeExists) and answer 409/skip with a log line when it did not. The Volumes listing also stopped re-reading the entire services/databases/attachments tables per volume (3V full-table reads before the first size measurement). - The one-click system export raced its own cleanup (r384). The handler unlinked the archive — the database, the master key and the
.env, every secret on the instance — in afinallythat ran in the same tick asreply.send(), before the read stream had necessarily opened the file: intermittent ENOENT downloads on Linux, and on Windows (where an open file cannot be unlinked) a permanent secret-bearing file left in the data dir. Cleanup now runs on the stream'sclose, and crash-orphaned export artifacts (ninedeploy-backup-*,_db-*,_env-*,_meta-*) are swept by hourly housekeeping. - A stalling registry hung the deploy pipeline and the registry lock forever (r385).
docker loginwas the one engine subprocess without a timeout; a registry that accepted TCP and never answered left the deploymentbuildingand every later deploy on the same registry queued behind the never-released lock. Login now times out after 120 s, kills the child, and fails the deploy with the registry named. - The update path was racy at three layers (r386–r389). Two
POST /update-startin the same milliseconds both passed the running-check and both spawned an updater (check-then-write); install.sh had no lock at all, so a self-update could interleave with an operator's SSH run (rm -rf/tar -x/pnpm installon one tree, two rollback manifests); and the confirm dialog firedonConfirmon every click.startSelfUpdatenow claims an exclusive-create lock (stolen after 10 s if the claimer died), install.sh holds a per-install-dirflock(30-minute wait), and ConfirmDialog disables after the first click. Also in this set: a freshinstall.sh --dockerrun as a non-root user no longer fails its own compose provenance check (sudo'senv_resetstripped the dummy variables — they travel in an--env-filenow), an explicit--bare-metalrefuses to build a second panel over a docker install, and a failed launch no longer reports "The updater exited null". - Stopping the panel killed deploys the worker was cleanly finishing (r390). The deploy worker's shutdown grace is 60 s (in-flight deploys checkpoint-cancel inside it), but the systemd unit SIGKILLed the cgroup at the default 30 s — every panel update or restart that landed mid-deploy left the row
buildingand its service undeployable until the 45-minute staleness sweep.TimeoutStopSec=90in the unit andstop_grace_period: 90sin the compose file honor the designed window. - Monitoring showed the master host's numbers as a remote node's (r391). Selecting a node card relabeled the overview cards "Node CPU / Node Memory / Node Storage" while
/v1/statsonly ever returns the panel host's data — an operator diagnosing a node read the master's disk and RAM. The cards stay labeled "Host" and an explicit note says what the selection does and does not show. - Service settings bled across services (r392). Navigating from
/services/1?tab=settingsto/services/2?tab=settingskeeps the route mounted, and the Settings/Network/Volumes tabs hold per-serviceuseState(limits, replicas, preview pattern, target node): service B's form opened with service A's values and Save wrote them onto B. The tabs are now keyed by service id. - Copy buttons silently did nothing on plain-http panels (r393).
navigator.clipboardexists only in secure contexts, and the panel is routinely reached athttp://<server-ip>:<port>; the webhook secret, the agent enrolment command, the password-reset link and the Hub compose copy all no-op'd there (Hub even showed "Copied ✓"). A legacyexecCommandfallback restores every copy button, and the one-time-secret reveals no longer risk being dismissed unsaved. - Assorted (r394–r396). A local stack's status view showed only its first service (the fixed compose parser from
listStacksis now shared); insights refresh — a synchronous full repo clone on the request path — gained the same rate limit as the analysis route; a failed database-limit restart marks the rowerrorinstead of leaving itrunningwith no container; volume-snapshot rows on the Backups page disable Restore with a hint instead of clicking dead; volume-restore failures render an error card; the Deploys tab's hover-only actions are visible on keyboard focus; and the Services list's start/stop buttons actually disable while toggling (the state was written but never set).