Re-review (completeness sweep + Codex) found: Audit\Index::saveRetention wrote the global
audit_retention_days policy ungated (a non-admin could shrink retention and prune audit evidence)
-> manage-panel guard + @can-hidden control; Settings\Email::sendTest lacked the role guard that
mount/save already had -> manage-panel; DetectHoneytoken read only form-encoded bodies via post(),
missing a JSON {"api_key":"<canary>"} replay -> now scans json() too (still body-only, no query).
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>
Security audit (12-angle adversarial workflow) findings on the container→host boundary:
- .env was created world-readable (cp of a 644 .env.example, never chmod'd) exposing the APP_KEY
that decrypts the whole fleet's SSH vault. Now chown clusev + chmod 0600 before any secret write.
- The root updater followed attacker-planted symlinks in the container-writable ./run (update-phase.json,
update.log) → arbitrary root file write. Status writes now go via a temp + rename(2) (never follow a
link); the update transcript moved to the repo root (not the ./run bind mount).
- The generated UPDATE_HMAC_KEY was dead. The app now HMAC-signs the update-request marker
(config/clusev.php update_hmac_key; DeploymentService) and watch.sh verifies it before running a
root update, so a stray/limited write to ./run can't drive one (a full container compromise holds
the key, so this is a bar-raise + integrity check; the standing guarantee is the marker only ever
re-installs the trusted remote's code, never attacker code).
- force_kv/set_kv wrote unescaped values into a sed replacement — a & | or newline in an operator's
domain/email/port could corrupt .env or inject a sed command. Values are now CR/LF-stripped and
sed-escaped, and the HTTP port is validated numeric/in-range.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex R15 flagged that _clusev_ref_ok accepted malformed digest refs (short/non-hex,
which trigger a doomed pull instead of a build fallback) and refs carrying an embedded
newline in ANY segment (force_kv would then split .env, injecting an attacker-chosen key).
Anchor the check to an exact 64-hex sha256 digest and two ghcr.io path segments that each
start alphanumeric and otherwise contain only [A-Za-z0-9._-] — so no newline, "=",
whitespace or slash can appear anywhere in the ref. Malformed or tampered refs now fall
back to a local build and can never reach force_kv. Tests: +6 cases (short, non-hex,
path-traversal, ".." name, newline in digest, newline in owner).
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.
Backbone sub-project #1, CI half (verified with actionlint; the runtime proof is
the first rc/stable tag push — these jobs cannot run locally).
- ci-staging.yml:
- new `images` job (needs: test): buildx builds clusev-app (Dockerfile) +
clusev-terminal (docker/terminal/Dockerfile) → private GHCR, tagged by
version; then a framework smoke-boot (`php artisan route:list`) so a class
stripped by export-ignore that is still reachable on boot fails before promote.
- `deploy-staging` widened to any tag; pulls the CI-built private image
(CLUSEV_IMAGE/CLUSEV_TERMINAL_IMAGE + CLUSEV_PULL=1) instead of building.
- new gated `promote` job (needs: [test, images], if success + stable vX.Y.Z,
no -rc): copy the exact image digests to the public GHCR, run promote.sh
(leak-guard + export-ignore tree), write release-images.lock, push code+tag.
- promote-public.yml: repurposed as a guarded manual fallback mirroring the auto
promote (image copy + lock + guard). Dropped the "beta channel" naming.
- promote-stable.yml: removed (no beta→stable step in a single-channel model).
Deploy inputs are GitHub vars/secrets (GHCR_OWNER, PUBLIC_GHCR_OWNER,
PUBLIC_REPO_SLUG, STAGING_*, GHCR_TOKEN, PUBLIC_REPO_TOKEN, PROMOTE_FORBIDDEN) —
never hardcoded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dashboard-triggered update that failed on the host left the /update-progress
page spinning to a vague 10-minute timeout — no error was ever shown, because
nothing wrote a failure marker for the page to read. Fix: make failures visible
and prevent the credential-prompt hang class.
- docker/restart-sentinel/watch.sh: run update.sh under `timeout -k 30 1800` with
its output tee'd to run/update.log; on ANY non-zero exit (update.sh or its
exec'd install.sh) — and on the early compose-missing / updater-missing paths —
write {"stage":"error"} to run/update-phase.json via write_update_error().
- update.sh: export GIT_TERMINAL_PROMPT=0 + GIT_HTTP_LOW_SPEED_* and wrap the pull
in `timeout 300`, so a private-repo credential miss fails fast instead of
hanging on a non-interactive prompt.
- DeploymentService::updatePhase(): whitelist the 'error' stage.
- update-progress.blade.php: add an error state (#js-status-error) + showError();
the status-feed poll now stops and shows it on stage=error, marking the active
phase red — instead of looping.
- lang/{en,de}/update.php: error_heading / error_hint / back_button.
- UpdateProgressTest: feed surfaces stage=error; page renders the error branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-panel help "First login" section still claimed you log in with the
literal default password `clusev` and that "the panel forces a password change
immediately". Both are false since this cycle: the installer generates a RANDOM
password shown once in the closing summary, and EnsureSecurityOnboarded only
nudges (skippable), never forces.
- help/overview.blade.php (en+de): rewritten — sign in with the initial
password from the installer summary; setting your own is recommended (a
banner reminds) but not forced.
- lang/{en,de}/auth.php: default-password banner → "Initial password still in
use" / "Initialpasswort noch aktiv".
- ResetAdmin.php: reset output "Einmal-Passwort" → "Neues Passwort".
- EnsureSecurityOnboarded.php: comment wording aligned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Password rotation is a nudge, not a wall (Install sets must_change_password=true
but EnsureSecurityOnboarded only prompts and is skippable), so calling the
generated credential a "one-time password" wrongly implied forced/single-use
rotation. Renamed the user-facing term everywhere:
- README + lang/{en,de}/accounts.php: "one-time password" / "Einmal-Passwort"
→ "initial password" / "Initialpasswort"; temp_intro "must change on first
sign-in" → "prompted to change" / "sollte … geändert werden".
- Install.php $description: "Standard-Passwort (Zwangswechsel …)" → "zufälligem
Initialpasswort (Wechsel empfohlen, nicht erzwungen)".
- Install.php / CreateUser.php comments aligned.
Kept "one-time backup codes" (2FA codes are single-use) and "shown only once"
(the summary is displayed once).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator-facing docs must show the short host commands, not the long
docker-compose invocation (same convention already enforced for the in-panel
help/versions/MOTD surfaces):
- Updating: `sudo ./update.sh` -> `sudo clusev update`.
- Recovery: `docker compose -f docker-compose.prod.yml exec app php artisan
clusev:reset-admin` -> `clusev reset-admin`.
Add README.md to CommandShortcutsTest's DISPLAY_SURFACES and
UPDATE_COMMAND_SURFACES so the README is regression-guarded too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar "Help" item, the command-palette entry and the `g h` shortcut now
open the public documentation site in a new tab instead of the in-panel help
page — no duplicated docs to keep in sync.
- config/clusev.php: add `docs_url` (env CLUSEV_DOCS_URL, default
https://docs.clusev.com) so the docs host is configurable per install.
- sidebar + command-palette: Help href -> config('clusev.docs_url'),
target=_blank rel=noopener.
- app.js: navigate() opens absolute http(s) URLs via
window.open(_, '_blank', 'noopener'); go() resolves leader keys absent from
CMDK_GO via the (config-driven) nav list, so `g h` follows the docs URL.
The in-panel /help route/component is kept (unlinked) as an offline fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Quick start: the installer no longer asks for a domain — it installs over IP/HTTP and prompts only
for the HTTP port + admin email; a domain (auto-HTTPS) is set later in the dashboard.
- The first admin now gets a RANDOM one-time password (shown once in the summary), not the literal
'clusev'; rotation is recommended (a warning banner nudges) but no longer forced.
- add the web terminal to Features (per-server + Clusev-host SSH, host-key pinned); mention the
first-run guided tour; point the domain/TLS section at the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel domain is a dashboard concern now (System page → panel_domain override + Caddy
on-demand TLS), so the installer no longer asks for it. A fresh interactive install goes
straight to bare-IP/HTTP (prompts only for the HTTP port + admin email). The domain still
works everywhere it needs to: CLUSEV_DOMAIN=… preset for scripted installs is kept, and a
non-interactive re-run (update.sh) still preserves the existing .env domain; the closing
banner and the bare-IP warning now point the operator to Dashboard → System for the domain.
shellcheck clean; bash -n OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A guided tour for new operators: a dimmed+blurred overlay with an enterprise description card
(mono eyebrow "01 / 06", display title, body, progress bar, back/skip/next). A step with a
target spotlights the matching sidebar nav item (a box-shadow "hole" lights it while everything
else dims); welcome + finish are centred cards. On narrow viewports (sidebar hidden) it falls
back to centred cards.
- auto-opens once per account (users.onboarding_tour_completed_at is null → autostart); skip or
finish calls Tour::markSeen() which stamps it, so it never auto-opens again.
- relaunchable any time from Settings (a client-side 'onboarding:start' window event — no DB change).
- @persist'd in the layout + a sessionStorage guard so a wire:navigate or fast reload right after
dismissal can't re-open it before markSeen persists.
- nav-item now merges its attribute bag (data-tour passes through without duplicating class/href).
Browser-verified: auto-open → spotlight walks Dashboard/Servers/Terminal/Settings → skip stamps +
closes → navigation doesn't reopen → Settings relaunch works; zero console errors. 4 feature tests;
full suite 473 pass; Codex review CLEAN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ssh2 sidecar connected with no host-key verification, so a MITM could present its own key
and capture the (decrypted) SSH credential — the phpseclib fleet path pins via TOFU but the
sidecar did not. The resolve endpoint now returns the server's TOFU-pinned host key; the sidecar
verifies the presented key against it (crypto.timingSafeEqual) during the handshake, before any
credential is sent.
parseHostKeyPin distinguishes three states so a corrupted record can't silently unpin:
null → no pin (Clusev host / never-contacted server) → accept, TOFU first use
false → a pin is present but malformed/undecodable → FAIL CLOSED (reject)
Buffer → enforce the pin (reject a mismatch as MITM)
Verified in-browser against a real server: valid key connects + runs; a swapped key is rejected
as MITM; a garbage non-empty key fails closed — all before any credential leaves the sidecar.
Full suite 469 pass; Codex re-review CLEAN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rotation is optional now, so a well-known default ('clusev') would be a standing takeover
credential on a fleet-root console. The installer now creates the first admin with a random
Str::password(16), printed once on the CLUSEV_ADMIN_PASSWORD= line that install.sh already
surfaces in the closing banner. must_change_password still nudges a rotation, but even if the
operator keeps it, the secret is no longer guessable.
Verified: the printed password authenticates + differs per install; noop-when-admin-exists is
intact; Codex review APPROVED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal resolves to an SSH login now (resolve never returns kind:'host'), so the
sidecar's local-shell path was unreachable:
- docker/terminal/server.js: drop startHost(), the spec.kind==='host' branch, the node-pty require
and HOST_SHELL/HOST_CWD constants — the sidecar only ever opens an SSH PTY.
- Dockerfile: drop the python3/make/g++ toolchain (only there to compile node-pty's native addon);
package.json: drop the node-pty dependency. Leaner, faster image.
- compose (dev+prod): drop the now-unused TERMINAL_HOST_CWD env and the .:/workspace:ro mount.
- remove grep-confirmed-unused lang keys (settings 2FA/stub-tab keys, accounts twofa/cannot_remove,
auth recovery_done/regenerate_confirm, common back/retry/more/loading, servers firewall/fail2ban
unavailable variants) and the unused x-server-item Blade component.
Verified: lean sidecar rebuilds + the server terminal still connects/runs; full suite 467 pass; all
pages 200 with no raw-key leaks and no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes from the security audit (the brute-force/rate-limiting angle matters more now that the
password policy is min(6)/no-complexity, so IP-keyed throttles must not be spoofable):
- bootstrap/app.php: trustProxies(at: '*') → trust only PRIVATE ranges in prod. Caddy reaches
app:80 from the docker bridge (private IP) so it stays trusted, but a public/off-network source
can no longer forge X-Forwarded-For to spoof request()->ip() and bypass the login/2FA/forgot
throttles + the brute-force ban.
- TerminalSession + HostCredential: replace $guarded=[] with an explicit $fillable allowlist; add
$hidden=[secret,passphrase] to HostCredential so it never serializes its credential fields.
- nginx /terminal/ws: access_log off — the single-use session token rode the query string into the
access log.
Verified: full suite 467 pass; all panel pages 200 with zero console errors; server terminal still
connects + runs commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator decision: password strength is the user's own responsibility. Removed the
mixedCase + numbers requirement from every password rule (Password::min(6) is all that
remains), so a plain 6-char password ("abcdef") is accepted. Dropped the
"upper/lowercase + a digit" clause from the hints (de + en).
Verified: a 6-char simple password validates; a 5-char one still rejects (length); full
suite 467 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator decision — 12 was too long; 6 is acceptable and password strength is the user's
own responsibility. Changed Password::min(12) → min(6) in every place that validates a
password (password change, forgot/reset, settings profile, create-user) and updated the
visible hints (de + en). The upper/lowercase + digit complexity is unchanged — only the
minimum length was flagged.
Verified: a 6-char complex password ("Abc123") now validates; too-short and complexity-less
passwords still reject; the password page shows "min. 6"; full suite 467 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Add-account modal only ever generated a one-time password — there was no way to set
one. Add an optional password field (with a show/hide toggle):
- blank → previous behaviour: a strong one-time password is generated, the account is
flagged must_change_password, and the clear text is revealed once.
- filled → that becomes the user's own login (validated min 12 / mixed case / digit);
must_change_password stays false (no forced rotation) and nothing is revealed.
Browser-verified: the modal shows the password field + hint; both paths covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- sidebar: the "2FA on/off" text badge next to the user name was visually noisy. Replace it
with a single status shield — a cyan shield-check when 2FA is on, a muted struck-through
shield when off; the label moves to the hover/aria title so it stays reachable.
- terminal: the server-target search now appears once there is more than one server (was 5+),
so it is there as soon as filtering helps; added a clear (×) button + an accent focus ring.
Browser-verified: sidebar shows just the shield (no text); search filters the rail (homelab →
only homelab-01) with a working clear button; both badge states render correctly. 465 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stored secret is never echoed back (security), so on reopen the blank password field
read as "not configured" / "it forgot my password". The login DOES persist (single
HostCredential row, kept across reloads); only the modal was ambiguous.
- when configured, show a green status: "Configured as <user> — password is stored. Leave
blank to keep it; enter a new one to change it." The Remove button + gear stay, so the
operator can still change the password or switch access.
No behaviour change — set once, stays until changed/removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal is for the LOCAL machine Clusev runs on. Letting the operator
point it at an arbitrary host/server makes no sense (that is what fleet servers are for)
and the pre-filled host.docker.internal read as a confusing mandatory magic value.
- the host is now FIXED to host.docker.internal server-side (HostShell::HOST), not a form
field; the modal asks only for the login — user + password|key, and the port.
- the rail tile sub-label shows just the login user (the host is implied/local).
Browser-verified: modal has no HOST/IP field (user/port/auth/password only); saving shows
the user on the tile; opening the host reaches the real host sshd (a wrong password yields
an SSH auth error, proving the connection), zero console errors. 16 terminal tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The field is pre-filled with host.docker.internal — a Docker DNS name that resolves to
the machine the containers run on (the Clusev host) — but had no explanation, so it read
as a mandatory magic value. Add a one-line helper under the field: it's the default for
the Clusev host; leave it, or enter another IP/host. The field stays editable on purpose
(some setups want the host's LAN IP or a different machine).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The seeded-password rotation was forced before the panel was reachable. It is now a
nudge, not a wall: the prompt shows once per session and can be skipped, and a standing
warning banner keeps reminding while the default password is still in use.
- EnsureSecurityOnboarded redirects to the password page only when must_change_password
AND the session has not set onboarding.password_skipped (set by PasswordChange::skip,
which lands on the dashboard WITHOUT rotating — so the flag/banner persist).
- app layout shows a default-password warning banner while must_change_password is true.
- the metrics broadcast channel no longer gates on securityOnboarded() — a deliberately
un-rotated (but authenticated) operator would otherwise lose realtime metrics;
/broadcasting/auth still runs in the web group (guest = 403), so it stays auth-gated.
- 2FA was already optional (never enforced) — only the login copy was wrong: "2FA
required/enforced" → "recommended/optional" (de + en).
Browser-verified (R12): login → skippable prompt → dashboard banner → free navigation
(no re-redirect); login copy correct; zero console errors. Full suite 465 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no
sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet
server — so it shows the real root@<host> prompt with full rights.
- HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets
the operator set host/port/user + password|key. The clear-text secret is never echoed
back; on edit a blank secret keeps the stored one, and switching auth method requires a
fresh secret (no password silently reused as a key).
- open('host') mints a kind=host token only when a login is configured, else opens the
setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted,
internal-net only) for kind=host, or 404 when unconfigured.
- compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host
sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly).
- rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line.
Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as
root runs commands, zero console errors; search filters; resolve returns the host spec.
15 terminal tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GitHub deprecated the Node 20 action runtime, so checkout@v4 and setup-node@v4 were
force-run on Node 24 with a warning. v5 of both targets Node 24 natively; their inputs
are unchanged (ref/repository/token/fetch-depth/path; the build's node-version stays
'20', that's the project runtime not the action runtime). setup-php@v2 is the current
major and unaffected. ci-staging runs only on tag pushes, so the warning clears on the
next tagged release.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a phone the terminal clipped on the right: FitAddon measured the column count before
JetBrains Mono finished loading (narrower fallback metrics → too many columns → the wider real
font then overflowed and the right edge was cut). And the PTY opened at a fixed 80x24, so the
remote shell's first screenful (MOTD/prompt) was generated at 80 columns and wrapped awkwardly
on a ~43-column viewport.
- refit immediately, on the next animation frame, and again on document.fonts.ready (font race)
- refit on the 'ready' frame before sending the size
- pass the browser's cols/rows on the WS URL; the sidecar opens the host PTY / SSH shell at that
size (clamped 1..1000), so output is born at the visible width
Verified in-browser at 390px: PTY reports 43x23 (matches the box), no horizontal overflow, clean
wrapping, zero console errors; desktop server+host terminals and cross-origin rejection still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host terminal is a shell in the sidecar container; it opened in the read-only
repo mount (/workspace) with the prompt showing the random container id, so it read
like a stray container rather than "the Clusev host".
- give the sidecar a stable `hostname: clusev` (dev + prod) → prompt host is `clusev`
- land the host PTY in /home/node (a real, writable home) and set HOME to it, so the
shell opens at ~ like a normal terminal instead of /workspace
Server (SSH) terminals are unchanged. Verified in-browser: prompt reads node@clusev,
pwd is /home/node, HOME=/home/node, no container hash / no /workspace; server terminal
still connects and runs; zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>