The re-audit's top finding (HIGH): update.sh runs as root, pulled + re-exec'd its own new copy +
ran install.sh's `docker compose build` on the pulled tree with NO signature check, so a
compromised/redirected origin or a transport MITM could feed attacker commits into a root RCE. The
branch-pin (bc2f2b5) stops a local `git checkout` redirect but not a remote origin compromise —
only a signature does.
- update.sh: after the pull and BEFORE the self-update re-exec / install.sh build, verify the pulled
HEAD with `git -c gpg.ssh.allowedSignersFile=<anchor> verify-commit HEAD`. Opt-in and safe by
default — skipped with a warning when no anchor is configured (existing installs keep updating),
but FAIL CLOSED once configured. The anchor is resolved from .env CLUSEV_ALLOWED_SIGNERS or the
gitignored .clusev-allowed-signers; a SET-but-missing anchor dies (no silent downgrade), and a
git-TRACKED anchor is refused (a pulled tree could otherwise swap in the attacker's own key).
- clusev-release.sh: opt-in SSH signing of the release commit + tag when CLUSEV_SIGNING_KEY is set,
so the verify side has provenance to check. Unsigned (identical behaviour) when unset.
- .gitignore /.clusev-allowed-signers (the trust anchor must never be pull-overwritable);
.env.example documents CLUSEV_ALLOWED_SIGNERS.
verify-commit + allowed-signers proven end-to-end in a scratch repo (signed→pass, unsigned→fail,
wrong-key→fail, tracked-anchor→refuse, set-but-missing→fail-closed). shellcheck clean. Activation
(provision the maintainer keypair + sign releases) is a documented maintainer step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first hardening pass created the update-phase.json temp with mktemp INSIDE ./run (the
container-writable bind mount) and reopened it by pathname (`> "$tmp"`). A container racing the root
writer could unlink that temp and drop a symlink in its place before the reopen, redirecting the root
write outside ./run. All THREE status-writers now build the temp at the repo ROOT — same filesystem as
./run (so the follow-up mv -f is an atomic, symlink-safe rename(2)) but NOT mounted into any container,
so it cannot be tampered mid-write: watch.sh run_write_json, update.sh set_stage, AND install.sh
set_stage (reachable as root directly and via update.sh -> exec install.sh). The transient /.phase.*
temps are gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit leak-hygiene findings:
- The operator's real internal dev-VM/WireGuard IPs (10.10.90.136/.162/.165) were shipped in
vite.config.js (hmr.host), lang hint/placeholder strings, a code comment and the CHANGELOG.
vite HMR host is now env-driven (VITE_HMR_HOST, dev .env only); all other occurrences are replaced
with RFC5737 documentation addresses (203.0.113.x). Tests updated in lockstep.
- docs/ is export-ignored from the git tree and named in promote.sh's refuse-to-publish list, but was
never .dockerignore'd, so `COPY . .` baked the private docs (with private host/URL refs) into the
public image, which the git-tree leak-guard never inspects. Added docs/ (and art/) to .dockerignore.
- /update.log (new repo-root update transcript) gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review found the build fallback could inherit a stale digest pin (compose build aborts), a slash-shaped/non-ghcr lock ref caused a doomed pull, env mode never persisted the pin (host restart used a nonexistent default tag), and the resolver test was unlinted/unrun in CI. Resolver now always leaves a coherent pin, validates ghcr.io/<owner>/<name>@sha256 refs only, exports the mode flag (drops the file-level shellcheck disable), and CI lints + runs the script unit tests.
The GitHub-private staging repo is a full push-mirror of Gitea, so export-ignore
(which only protects the public repo via git archive) does not strip these from
the mirror. Untrack the internal/AI-collaboration docs — CLAUDE.md, rules.md,
handoff.md, kickoff-prompt.md and docs/ (specs, plans, runbooks, handoffs) — and
gitignore them so they stay on the dev box but never reach Gitea, the mirror, or
any shipped image. The public repo was already clean via export-ignore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- serve-request.test.sh: the push-fail case pointed origin at a dead https remote,
so _precheck's fetch failed first and the rollback path in _do_stage was never
reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote
(pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs;
also assert the tree is restored clean.
- clusev-release.sh: |\| true on the version extraction (defensive under pipefail).
- install.sh: make the release-unit install best-effort (if/then/warn) like the WG
block so a systemctl failure cannot abort the installer.
- .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep.
- docker/release/README.md: dev-watcher runbook (the manual end-to-end gate).
- ReleaseBridgeTest: assert a rejected target writes no request file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.
How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
DIRECTLY (cache-independent) and retries until the settings table is readable; if it
never is, it freezes the env fallback so the active domain is always a FIXED value
(never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
(/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
command; DE/EN strings (R16).
Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
always a recovery path. trustProxies('*') only in production (dev can't be tricked into
faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
and IP-recovery redirects stay on the IP.
Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).
Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generate the two governing docs from handoff.md:
- rules.md: STRICT RULES R1-R10, each with rationale + correct/forbidden example
- CLAUDE.md: product, stack, folder map, commands, conventions, before-you-code checklist
Add a $HOME-rooted .gitignore that blocks all secrets and home-dir dotfiles
(explicit-add-only workflow). Track handoff.md + kickoff-prompt.md as context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>