Monitor the certificate expiry of configured TLS endpoints (the panel's own domain, fleet
services, anything reachable) and flag the ones due soon — before they lapse.
- CertService::check(host, port) reads the peer certificate NATIVELY via a PHP SSL stream +
openssl_x509_parse — no SSH, no shell, so `host` is never interpolated into a command. Trust is
intentionally NOT verified (verify_peer off): the goal is to report expiry even for a self-signed
or already-expired cert. certInfo() (subject/issuer/expiry/daysLeft) is split out for unit testing
against a generated cert; status() maps daysLeft → ok / warning (<30) / critical (<7) / expired.
- Certs\Index page (route /certs, `operate`-gated: route + mount + per-method). Add/remove endpoints
(host validated as a hostname/IP — rejects scheme/path/shell chars; port 1-65535). Lazy per-endpoint
scan, each guarded. Delete via signed ConfirmToken + R5 modal. Add/delete audited. Internal
endpoints are allowed on purpose (monitoring internal service certs is legitimate; the check only
reads a cert, it POSTs nothing).
- lang/{de,en}/certs.php + audit.php cert.endpoint_* + shell.nav_certs (de/en parity). No emoji.
10 new tests: service (certInfo extracts subject/expiry from a generated cert, status thresholds,
check errors on an unreachable endpoint), component (route gating, add + audit, reject scheme/
metachar host + out-of-range port, scan checks each endpoint, delete via confirmed token). 730 tests
green, Pint, lang parity, self-reviewed. LE issuance is a documented future (needs ACME on the host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fleet-wide package-update visibility: pending + SECURITY update counts per server, one-click patch.
Extends MaintenanceService (single-server pending/apply already existed) with the security subset
and a fleet aggregation.
- MaintenanceService::updateCounts(Server) → { pending, security } in ONE round-trip via a new
PackageManager::countsScript() (apt/dnf/zypper). Security is best-effort per family (apt path solid;
dnf/zypper degrade to 0 when the metadata isn't there) and clamped ≤ pending. Either value is null
when the query can't run (shows "unknown", never a misleading 0).
- Patch\Index page (route /patch, `operate`-gated: route + mount + per-method). Lazy per-server scan,
each guarded (an unreachable host → error row). Fleet KPIs (total pending / total security). Applying
upgrades is a stateful action → signed ConfirmToken + R5 modal (modal audits patch.apply from the
sealed token; handler does not re-audit) resolving a client uuid → the sealed server id; a forged
token is a no-op. Reuses the existing applyUpgrades; the result output is mb_scrub'd before it lands
in a Livewire prop.
- countsScript strings are STATIC per manager (no operator input) — no injection surface.
- lang/{de,en}/patch.php + audit.php patch.apply + shell.nav_patch (de/en parity). No emoji.
11 new tests: MaintenanceService (parse pending+security, clamp security≤pending, missing-sentinel /
unsupported-manager → unknown), component (route gating, scan shows counts + survives an unreachable
server, patch opens confirm, applyPatch runs upgrades over the SEALED server, forged token no-op).
720 tests green, Pint, lang parity, self-reviewed (RBAC, ConfirmToken seal, static scripts, scrub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A per-server hardening SCORE + a fleet scoreboard, so an operator sees at a glance which servers
are hardened. Reuses the (already-reviewed) HardeningService::state gates rather than re-probing;
remediation for a failing check is the existing hardening toggle on the server-details page.
- PostureService::score(Server): aggregates the hardening state into { score 0-100, rating
strong/fair/weak, passed, applicable, checks[] }. Only real security gates count — the neutral
auto-updates preference and OS-unsupported items are excluded, so no server is penalised for a
check it can't run.
- Posture\Index page (route /posture, `operate`-gated: reading a server's posture exposes its gaps).
Lazy wire:init scan of every active-credential server, each guarded — one unreachable host shows an
error row, never blocks the scan. Fleet-average KPI + per-server score/rating + the open gates.
- lang/{de,en}/posture.php + shell.nav_posture (de/en parity). Status via the triad, no emoji.
9 new tests: service (score = passing/applicable ratio, all-secure=strong-100, neutral+unsupported
excluded, all-fail=weak, no-applicable=full), component (route gating viewer 403 / operator ok, scan
scores each server, an unscannable server shows an error row not a crash). 709 tests green, Pint,
lang parity. (No destructive actions / new SSH — a read-only aggregation of Codex-validated hardening
state; self-reviewed for RBAC, scan-loop containment, and blade escaping.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The multi-server amplifier: run a command (ad-hoc or a saved runbook) across the whole fleet,
a group, or a selection, with a per-server result. Because it is arbitrary remote execution,
authorization + audit are the whole feature.
- The command is intentionally arbitrary; it runs via FleetService::runPlain (the credential's
login user, base64 transport, NO extra sudo) — the same privilege the web terminal already
grants, batched. No injection to prevent; the concern is who may run + that every run is logged.
- CommandRunner.run: per-server runPlain, one server failing never aborts the batch, output
byte-capped (256 KiB) + mb_scrub'd before it reaches a Livewire property.
- Commands\Index page, fully `operate`-gated: route can:operate + mount() + a per-method gate() on
run/runRunbook/execute/createRunbook/confirmDeleteRunbook/deleteRunbook. Every run goes through a
signed single-use ConfirmToken + R5 confirm modal ("run on N servers?"); the modal audits
command.run from the sealed token so execute() does not re-audit. Targets resolve client UUIDs →
ids server-side (withActiveCredential + inGroup + whereIn), and {command, serverIds} is SEALED in
the token — a client can't swap the command or targets after confirm. A forged token is a no-op.
- Runbooks: saved name+command, operate-gated CRUD, delete via ConfirmToken.
- lang/{de,en}/commands.php + audit.php command.run / runbook.* + shell.nav_commands (de/en). No emoji.
15 new tests: CommandRunner (runs each server, one-failing-doesn't-abort, nonzero=not-ok, byte-cap
+scrub), component (route gating, ad-hoc run opens confirm, empty-command / no-target errors, execute
runs the SEALED targets, forged token runs nothing, group scope targets only members, runbook create
+audit + run + delete-via-token). 700 tests green, Pint, lang parity, Codex-reviewed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manage the containers running ON a fleet server, agentlessly — the integrated Portainer.
List containers + state, view logs, start/stop/restart, all via `docker` over SSH.
- DockerService (over FleetService::runPrivileged → base64 transport, shell-injection-safe):
containers() parses `docker ps -a --format '{{json .}}'`; containerAction() with an op allow-list
(start|stop|restart|pause|unpause); logs() with a clamped tail; composeStacks() read-only. Every
container ref is validated `^[a-zA-Z0-9][\w.-]*$` before interpolation — blocks shell metacharacters
AND a leading-dash argument injection (mirrors serviceAction). A missing/errored docker → [] (never
throws to the UI).
- Docker\Index page (route /docker, sidebar "Docker" with a new box icon): lazy container list for the
active server, open to any role. Container actions are `operate`-gated (abort_unless per method) and
audited (docker.action). Reading LOGS is also operate-gated (container output can leak secrets —
consistent with gating file CONTENT reads); the list/state stays open. ContainerLogs modal re-gates
the read itself.
- lang/{de,en}/docker.php + audit.php docker.action + shell.nav_docker (de/en parity). No emoji.
Codex hardening applied: logs output is byte-capped (head -c 262144) AND mb_scrub'd before it reaches
a Livewire property (invalid UTF-8 / a huge line can't break the JSON snapshot); the ref regex uses
\A…\z anchors so a trailing newline can't slip past.
16 new tests: service (json parse, empty-on-unavailable, valid-op command, reject unknown-op / shell-
metachars / leading-dash / trailing-newline ref, logs clamp+byte-cap, available probe), component
(viewer browses the list; viewer 403 on action AND on logs open + modal read; operator/admin act +
audited; logs modal tail). 685 tests green, Pint, lang parity, Codex-reviewed (fixes applied).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turns the existing metrics into an ops tool: rules that fire when a server crosses a
threshold and resolve when it recovers, with e-mail + webhook notifications. The event
sink later features (cert-expiry, uptime-down, patch-available, posture-drop) plug into.
- alert_rules (metric cpu|mem|disk|load|offline, comparator gt|lt, threshold, scope
all|group|server) + alert_incidents (firing|resolved, FK cascade).
- AlertEvaluator — per-server state machine: opens ONE firing incident on a fresh breach
(notifies), resolves on recovery (notifies), dedups a sustained breach (one breach = one
alert). Numeric metrics skip a truly-offline server (stale reading); the `offline` rule
fires only on offline, not a reachable "warning".
- AlertNotifier — best-effort e-mail (queued Mailable to configured recipients, falls back
to admin e-mails) + generic webhook JSON POST (Slack/Discord/Mattermost/custom; only http(s)
URLs). Every channel is wrapped so a broken SMTP/unreachable hook is logged, never thrown.
- PollMetrics evaluates after each poll (online, fresh status) and on a failed poll (offline),
each guarded so an alerting error can never kill the poll loop.
- Alerts\Index page, manage-panel (admin): route can:-mw + mount() + per-method gate() on every
mutation. Rule delete via signed ConfirmToken + R5 modal (no double audit). Scope group/server
resolves a client UUID to an id server-side (never trusts a client id). Channels saved to
Settings. Sidebar "Alarme" nav + firing-incident badge (cached 60s).
- lang/{de,en}/alerts.php + audit.php alert.* + shell.nav_alerts (de/en parity). No emoji.
Codex review raised three MEDIUMs, all fixed here:
- Webhook SSRF: strict http(s) scheme + reject hosts resolving to loopback/private/link-local/
reserved ranges (blocks the 169.254.169.254 cloud-metadata classic), validated at save AND send,
redirects disabled — so an admin-configured hook can't be pointed at an internal service.
- Incident dedup is now a DB invariant: a unique `firing_key` ("{rule}:{server}" while firing,
null on resolve) means two concurrent poll ticks can't both open a duplicate incident (the loser
hits a unique violation, caught → no-op).
- `load` is float-safe: threshold + incident value are decimal, compared as floats, so load 1.5 vs
threshold 1 fires (it used to truncate to 1 > 1 = false).
31 new tests: evaluator (fire/resolve/dedup, offline vs warning, scope all/group/server, disabled,
fractional-load, firing_key unique, key-freed-on-resolve), notifier (email/fallback/webhook/
non-http-ignored/failure-swallowed/SSRF-reject/public-accept), component (RBAC route gating, rule
CRUD, uuid→id scope resolve, offline→threshold 0, toggle, delete via token, channels, unsafe-webhook
rejected, incidents render). 669 tests green, Pint, lang parity, Codex-reviewed (fixes applied).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Foundation for the feature program (groups are the scoping primitive alerts, ad-hoc
commands, bulk actions and patch/posture will target). Lean by design: named groups,
no hierarchy, no per-group RBAC.
- server_groups (uuid route key, unique name, color = a @theme token key coerced to a real
token on save) + server_server_group M2M pivot (cascade on delete; servers survive a group
delete). Server::groups() + a `scopeInGroup`.
- Servers\Groups full-page component, manage-fleet (admin) only — route can:-mw + mount()
abort_unless + a per-method gate() on every mutation (create/rename/setColor/toggleMember/
confirmDelete/deleteGroup), since /livewire/update does not re-run route mw. create/rename/
recolour/membership audit directly; DELETE goes through the signed single-use ConfirmToken +
the R5 confirm modal (which writes group.delete from the sealed descriptor, so the handler
does not double-audit). 'groupConfirmed' added to ConfirmToken::ACTIONS.
- /servers/groups is registered BEFORE /servers/{server} so "groups" isn't captured as a uuid.
- Servers\Index: a #[Url] group filter (uuid; shareable) — an unknown uuid falls back to all,
never errors. Filtering is open to every role; only management is admin-gated. A "Gruppen"
button (admin) on the fleet header; colour pills use a literal @theme token map (R3).
- lang/{de,en}/groups.php + audit.php group.* labels (de/en parity). No emoji.
19 new tests (model + component: uuid/colour-allow-list/relations/cascade, RBAC route gating,
create/rename/recolour/membership, delete-via-confirmed-token, forged-token no-op, filter).
638 tests green, Pint, lang parity, Codex-reviewed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the re-audit's design/perf pass (Codex-reviewed):
- WithFleetContext::fleet() memoises per request (protected, not serialised by Livewire) —
activeServer() calls it several times per render (Files ~6x, Services ~4x), so it was re-running
the fleet query each time.
- Wireguard: cache WgTraffic->series() (60s) keyed on the window + a data fingerprint
(MAX(id) + COUNT) so a new/pruned sample busts it, but the 5s wire:poll no longer re-buckets the
whole sample history every tick (samples only land once a minute).
- dashboard: the systemd table headers (Unit/Status/Boot) were hard-coded English while a sibling
column used __(); move all four through lang keys (de+en parity).
- update-progress: localise the phase-list aria-label.
- btn: the compact `sm` size grows to the 44px R7 touch target on a coarse pointer only, so dense
row/toolbar buttons are tappable on mobile while desktop density is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed-security quick-wins from a full re-audit (6-agent subsystem fan-out,
Codex-reviewed). Each item ships with a regression test proven to fail pre-fix.
- files: gate file CONTENT reads (download / edit / FileEditor::load) behind the
`operate` ability so a read-only viewer keeps LISTING/browse access but can no
longer pull file bytes over the server credential (root -> /etc/shadow, keys,
.env). Hide the download/edit/delete controls from viewers in the blade, and
basename() Files::open() for parity with the other path builders.
- fail2ban: reject a leading dash/dot in validJail(). A jail name like "-h" or
"--help" was parsed by fail2ban-client as an OPTION (argument-injection) rather
than a positional jail; internal dashes ("nginx-http-auth") still pass.
- bruteforce: release the atomically-claimed ban-audit dedup slot when the
AuditEvent write throws, so a transient DB failure no longer suppresses the
auth.ip_banned audit for the whole 60s window (the ban itself is unaffected).
- update path: verify_update_request now FAILS CLOSED when UPDATE_HMAC_KEY is
unset (was fail-open -> any ./run marker could drive a root update). Pin the
self-update `git pull` to the recorded CLUSEV_BUILD_BRANCH and refuse a diverged
checkout, so a `git checkout` in the tree cannot redirect the next root update.
- wireguard: normalise the DNS input to a single comma list and QUOTE the WG_DNS
assignment in wg.env; an unquoted space previously split the assignment and
broke the next `. "$WG_ENV"`.
13 new regression tests. 614 tests, Pint, shellcheck, Codex review all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security re-audit + adversarial red-team of the honeypot deception and
brute-force ban layer. Fixes (all with regression tests):
HoneypotController::trap
- BAN EVASION: keyed ban off getRealMethod() not isMethod('post') — the
latter honors _method / X-HTTP-Method-Override, so a POST spoofed as PUT
dodged the ban entirely. Now any non-safe verb to a decoy bans.
- REFLECTED-BAN vs EVASION: removed the Origin-based skip; the Origin header
is attacker-controlled, so it was a trivial universal ban-evasion (send a
foreign Origin) worse than the narrow griefing it prevented.
- UNMASK/500: mb_scrub all attacker-controlled meta (ua/query/path/creds) so
invalid UTF-8 can't throw JsonEncodingException on the array-cast column.
- Ban now runs FIRST and independently of the audit (separate guarded
try/catch) so a failing audit can never skip the ban; deceive() always 200.
- AUDIT DoS: per-IP hourly cap via atomic Cache::add(0)+increment.
DetectHoneytoken::trip
- Same guarded ban-first / audit-second structure + per-IP audit cap
(bounds an exempt or rotating source) + mb_scrub on the path.
BruteforceGuard::banNow
- Dedup the auth.ip_banned audit per IP+reason on a 60s window (was
full bantime): collapses the scanner flood without masking a legitimate
re-ban after an operator unban, and still audits distinct reasons.
Supply-chain / release / dashboard
- Pin prod base images (caddy/mariadb/redis) by @sha256 digest.
- Atomic branch+tag push in clusev-release.sh (no orphan untagged commit).
- Route github.ref_name through a validated $TAG env in ci-staging.yml.
- Strip inline credentials from the origin URL in set-repository-url.sh
(greedy match handles an @ inside the password).
- WireGuard peer name via Js::from in wire:click; settings badge shows the
real role; Threats pill/top_ip account for honeypot_login attempts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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).
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>
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>
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>
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 "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 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>
A dedicated /terminal page with a target rail: one terminal per fleet server (SSH)
plus one for the Clusev host itself. php-fpm cannot hold an interactive PTY, so a
small Node sidecar (ws + ssh2 + node-pty) bridges xterm.js to the PTY.
Flow: the Livewire page mints a single-use, 60s TerminalSession token and dispatches
it to an Alpine xterm island; the browser opens a same-origin WS (/terminal/ws,
proxied by nginx in dev / Caddy in prod to terminal:3000); the sidecar burns the
token at an internal resolve endpoint (shared-secret header, private network only)
for a connection spec, then opens an SSH PTY (decrypted credential, never sent to the
browser) or a local host PTY. Real shell = native Tab/Shift-Tab completion.
Security:
- token burned ATOMICALLY (single conditional UPDATE) — no double-open race
- resolve endpoint guarded by hash_equals shared secret, exempt from CSRF + PanelScheme
host enforcement (private net only), returns decrypted creds over the internal net
- sidecar enforces same-origin on the WS upgrade (hostname match, port-agnostic)
- single-use 60s tokens, swept daily so the table can't grow unbounded
Verified in-browser (R12): host + server terminals connect, accept keyboard input,
run commands, Tab-complete; cross-origin WS rejected; zero console errors. 9 feature
tests cover minting, the resolve spec, single-use burn, expiry, and locked creds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The series rail synced ?series=<key> via Livewire #[Url] with a bare numeric key like
"0.10". Livewire type-juggles that to 0.1 (the trailing zero is lost), so reloading
while viewing the 0.10 series silently switched to 0.1 and rewrote the URL. Prefix the
series key with a non-numeric "v" (?series=v0.10) so it round-trips as a string; the
rail label still shows the dotted "0.10". Surfaced now that a 0.10 series exists.
Browser-verified: selecting 0.10 -> ?series=v0.10, and it survives a reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the Server-Details history graph from a static Livewire-rendered SVG into a
self-contained Alpine island fed by a JSON endpoint, addressing the feedback:
- Smooth Catmull-Rom curves instead of angular segments.
- Continuous line with an area gradient fill; the line breaks ONLY on a real outage
(delta-t > 2.5x the bucket), not on every empty bucket — no more dropouts.
- Hover crosshair + tooltip (time + CPU/MEM/DISK at the point).
- Instant client-side range switching (1h/24h/7d/30d) via
GET /servers/{server}/history.json — no Livewire round-trip.
- X-axis labels moved below the plot (no longer overlapping the 0 gridline).
MetricHistory::series() now returns only non-empty buckets plus from/now/bucket. The
chart wrapper is wire:ignore so the parent's 10s poll never clobbers the Alpine state;
the island self-refreshes every 60s. The old Livewire range logic is removed.
Codex review: clean (auth-gated route, range clamped, numeric-only JSON, guarded hover
bindings, R3/R4-compliant). Browser-verified: smooth curves, area fill, working tooltip
+ crosshair, instant range switch, zero console errors. 446 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A CPU/RAM/Disk history graph over selectable ranges (1h/24h/7d/30d) on the server
detail page, backed by a persisted time series (the live 15s view stays cache-only).
- metric_samples table (server_id cascade, cpu/mem/disk %, load, sampled_at indexed).
- clusev:sample-metrics (scheduled every minute, mirrors clusev:wg-sample): persists
one row per server from the cached live reading, prunes beyond --retention (30d).
- MetricHistory::series() buckets + averages samples for a window; empty buckets stay
null so the chart shows gaps, not fabricated values.
- Servers/Show: range selector + a gap-aware SVG line chart (theme-token strokes,
R3-compliant, mirrors the dashboard chart). historyRange is clamped via setRange()
and the updatedHistoryRange() hook (it's a public, client-settable property).
Codex review: fixed a right-edge bucket off-by-one, clamped percentages on write, and
guarded the public range property against direct client values. Browser-verified: the
chart renders with data, range buttons, legend and gaps, zero console errors. 447
tests green, pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "add SSH key" modal had no name field — keys appeared only by comment +
fingerprint, so additional keys weren't tellable apart. Add a name field; the name
becomes the key's OpenSSH comment (what the server list shows), for both generated
and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at
64 chars) so a crafted name cannot inject a second authorized_keys line — proven by
test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the Projekt-card change: the dev/release-control box must list its
actual update source (e.g. private Gitea) AND the public repo — the previous commit
showed only the source on dev. Replaced the single project link with projectLinks():
the public repo is always listed (validated slug, canonical fallback); the dev box
prepends its source when it is a real URL (a garbage/hostless source is dropped, never
an empty link). Staging/stable still list the public repo only — no private leak.
Codex review: no leak path on non-dev; hardened the dev source against a hostless URL.
Verified: 433 tests green (dev-both / non-dev-only / malformed-slug / garbage-source),
pint clean, /versions shows both links on the dev box with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions "Projekt" card linked config('clusev.repository') — the per-install
update source, which on the dev box is the private Gitea. A staging/stable install
must never surface a private source. Now only the dev/release-control box shows its
actual source; every other install links the public open-core repo only, built from
the public slug with a canonical fallback (a malformed or full-URL slug can never
produce a private/garbage link). The update SOURCE (ReleaseChecker) is unchanged —
this governs only the displayed link.
Codex review: hardened the slug to reject non-owner/repo values; the release_controls
gate is the dev-box invariant (it also gates the dev-only /release page, and a staging
box's CLUSEV_REPOSITORY is the public repo anyway).
Verified: 432 tests green (incl. dev / non-dev / malformed-slug cases), pint clean,
/versions loads 200 with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'Staging' is not a single server: any beta-channel server pulls a release itself,
so a per-server 'deployed' step in the dev dashboard was arbitrary + contradicted
that model. The pipeline now tracks only the global build facts of a beta —
Tag (Gitea) → Mirror (GitHub-private) → CI. Removes PipelineStatus::testStep, the
test-server Setting/input/saveTestServer, and the now-orphaned lang keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PipelineStatusTest: the private mirror slug 'boksbc/clusev-staging' was hardcoded
in this tracked test (tests/ ships to the public repo) — exactly the leak the
design forbids. Neutralise to an example slug 'acme/staging'. Add the missing
HTTP-error degrade tests (GitHub 5xx, GitHub throwing, test-server unreachable →
'unknown', never throws) — the core robustness guarantee was untested.
- Live rail: render the per-step description (the built $sub array was dead code)
and colour the live state by status (online/warning/offline).
- saveTestServer: refresh() the pipeline cache so a changed test-server URL is
reflected immediately, not after the 15s TTL.
- .env.example: document the dev-only release/pipeline keys (commented, no values).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a
GitHub-hosted public repo would never resolve a release. Centralise the repo-URL
parsing and branch by host: GitHub uses api.github.com for tags and
raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires);
Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so
all host logic lives in one place.
Make the update-check tests hermetic: they now set clusev.repository themselves
(neutral host) instead of depending on the ambient .env — so they pass in CI with
an empty .env.example — and add GitHub-host coverage for tags + changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a binary / non-UTF-8 file in the file manager threw "undefined is not
valid JSON". The editor bound the raw file bytes to its public $content
property; Livewire JSON-encodes the component snapshot, and invalid UTF-8 makes
PHP's json_encode return false, so the client received an empty response and
JSON.parse("undefined") failed (the trace in mergeNewSnapshot/deepClone).
Fixed at both layers: FleetService::readFile blanks the content when it detects
binary/non-UTF-8 (returns the binary flag instead), and FileEditor::load only
binds content for non-binary, non-oversize files. Binary/oversize files show
their existing notice; the snapshot is always valid UTF-8.
Tests: load blanks binary content (keeps the snapshot valid) and keeps valid
text content. 374 pass, Pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes the operator asked for:
- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
test masked it with a wildcard fake; tightened with Http::assertSent.
- The sidebar now shows a "1" badge on the Version item when an update is
available, so an update is visible on every page, not only the Versions page.
Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
(no network in the sidebar); a scheduled clusev:check-update (every 30 min,
anonymous/read-only) keeps the cache warm so the badge is accurate even before
the operator opens the Versions page.
Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.
Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit log loaded the latest 50 entries and filtered them in memory, so it
couldn't grow and search only spanned those 50. Now it is paginated (25/page)
with the page deep-linked in the URL via Livewire WithPagination (?page=), and
the search runs in SQL across the whole history — matching actor / action code /
target / server name AND the readable labels (a term is translated to the action
codes whose localized label contains it, so "Endpoint geändert" still finds
wg.set-endpoint). Pagination control mirrors the changelog's windowed pager.
Tests: 25/page with page 2 overflow, search resets to page 1, search filters in
SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint
clean, /audit loads 200 with no console errors, audit lang parity 62/62.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator
couldn't see what an available update contained until after applying it. Now,
when behind a published release, a "What's new in vX.Y.Z" panel shows the
changelog of the available version(s) — fetched from the remote CHANGELOG.md at
the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with
the shared changelog parser and filtered to versions newer than installed.
Degrades gracefully: a failed fetch yields an empty preview and never blocks the
update button. Refactored releases() to a reusable parseChangelog().
Tests: preview lists only newer versions + renders their entries; no preview
when current. 362 pass, Pint clean, /versions loads 200 with no console errors,
versions lang parity 51/51.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.
Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.
Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three things the operator asked for on the WireGuard page:
- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
active peers re-handshaking well within it, so no flapping; a disconnect now
shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
(auto-appended), no need to repeat the listen port.
Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>