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>
Surgical consistency/polish tightening within the existing "Tactical Terminal"
system — no re-theme, no new tokens/fonts/colors, every change uses an existing
@theme utility. From a design review (subagent-cataloged, false-positives rejected).
- nav-item: badge tooltip is now a contextual `badge-title` prop instead of a hardcoded
"update available" — the Threats count badge showed the wrong tooltip. Sidebar passes a
real "N login attempts (24h)" title for Threats and "Update available" for Versions.
- empty-state messages (sessions/users/webauthn-keys/login-protection) bumped from the
faintest ink-4 to ink-3 — when a list is empty that line is the focal point, not meta.
- modal actions: fail2ban-config / hardening-action / system-update submit buttons go from
the softer `accent` to solid `primary` so the real action reads as primary (cancel stays
secondary; the done-state close is unchanged).
- servers list: CPU/RAM mini-bar labels ink-4 -> ink-3 to match their values.
- edit-credential: top-level error now uses the standard bordered offline error box
(border-offline/25 bg-offline/10 + alert icon), matching firewall-rule.
- file-editor: title + path get a native `title=` tooltip so a truncated long path is legible.
- add-ssh-key label rhythm (mb-1.5 -> mb-1); login-protection textarea vertical padding
(py-2 -> py-2.5, the textarea norm); webauthn-keys add button w-full sm:w-auto so it isn't
a mobile orphan; app.css font comment de-staled (fonts are self-hosted).
Deliberately NOT changed (would harm the design, not help): mail from_name kept sans vs
from_address mono (name = prose, address = technical token — the convention); the Memory
metric tone kept dynamic/threshold-based (health signal) rather than forced cyan.
Verified: vite build compiles all utilities, 617 tests green, de/en lang parity, DOM render
check of the badge + button variants.
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>
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>
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 "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>
- 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>
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>
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>
Default update source, README (badge/clone/home), the console + source-view
branding, and the CHANGELOG compare links now reference the public repo. Private
URLs remain only in the gitignored .env (install.sh derives CLUSEV_REPOSITORY
from the clone origin). Bump 0.9.58.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A friendly HTML comment in the page source (both layouts, before <head>) —
ASCII "CLUSEV", what it is, and the project link — for anyone who View-Sources.
- A styled console banner printed on load (every page, incl. login): the CLUSEV
wordmark, a one-liner, the repo link, AND a self-XSS security warning. The
warning earns its place: a Linux-server control panel is a prime target for
"paste this into your console" social engineering, so the notice sits exactly
where that attack happens. console styling is best-effort and never throws.
Verified: /login 200, the console logs the brand + warning + link, the source
carries the comment, no page errors; 374 tests pass; Vite build 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>
Two dashboard additions to the WireGuard page:
- Peer config download: the show-once new-client view gets a "Download"
button next to the QR code (streams clusev-wireguard.conf) plus an
inline hint explaining QR-on-phone vs download-on-PC import.
- SSH lock (port 22): an optional toggle in WireGuard settings that walls
off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the
host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo +
wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over
the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot
unit's ConditionPathExists=wg0 and an in-function self-guard keep a
broken tunnel from ever applying the DROP; ssh_gate_apply re-validates
the subnet shape and fails open on a corrupt wg.env. Strong lock-out
warning shown inline AND in the confirm modal (recommend a backup peer).
New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears
both gates. Panel gate and SSH gate are independent toggles.
Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host
whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector
now reports gate.ssh. Adversarial lock-out review: all 8 failure modes
refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.
Also fixes, surfaced by an adversarial review of the change:
- icon: add the missing 'chevron-right' Lucide path — the accordion disclosure
caret and the pagination "Next" button were rendering an empty <svg>.
- blade: wire:ignore.self on each <details> so a user's open/closed toggle is not
reset by an unrelated re-render (autoCheck / check-updates / in-update poll); a
series/page change still re-defaults to first-open via a fresh wire:key.
- blade: 44px touch targets (series pills + pagination) below lg (R7).
- i18n: localised 'Other' series label; pluralised series_count (no "1 Releases").
- Index: render() is the single clamp authority for changelogPage.
Index.php: groupBySeries() + pageWindow() + selectSeries/gotoChangelogPage; render
builds the series list, resolves the active series, paginates. New DE+EN keys.
VersionsChangelogTest covers grouping order, default series, paging, clamp, the
installed-series cue, and the chevron-right icon regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace three divergent 2FA indicator styles (cyan x-badge in users list,
custom inline-flex chip in settings header, plain mono text in sidebar) with a
single x-two-factor-badge component using the cyan x-badge + shield icon style
and the canonical settings.two_factor_on/off lang keys throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.
Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.
CHANGELOG also backfills the prior reset-timing residual fix (2520b87).
Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
System page capped to a readable width; Speichern gets a proper save glyph (added
save/eye/eye-off/user-plus to the icon set); operator-facing 'Caddy' softened to
neutral wording; added a 'fully locked out -> SSH + clusev:reset-admin' hint. Settings
email field to type=text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last
N lines once and never refreshed, so it was neither live nor complete.
- FleetService: `journalctl --show-cursor` + new journalSince() reads only entries
after the stored journald cursor (no gaps, no dup appends), capped to a bounded
live tail; parseJournal split into splitJournalCursor + parseJournalLines.
- Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor
and caps retained rows at 300 (fetch ceiling == display ceiling).
- Honest labels: drop the misleading "-f"; badge stays "live" (now true).
Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine
cmdk x-data; servers surface only while searching, matched on name OR IP, and
selecting one navigates to /servers/<uuid>.
Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell —
no injection (Codex-reviewed: no P1, no security findings). Removes stock
ExampleTest (asserted 200 on an auth-gated route → always 302).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `pending` status renders cyan (dot + pill, soft ping) instead of red, for
freshly created servers that have not been contacted yet. Wired into the
status-pill/dot components, the fleet list, and server-item, with a bilingual
`servers.status_pending` label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every x-btn variant now carries a persistent border + background. Replace the
borderless `ghost`/`ghost-danger` (the latter only reddened on hover) with the
bordered `secondary` and a new `danger-soft` (red-tinted border+bg, always).
Migrate all 10 usages across servers/settings/files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes/hardenings on top of v0.4.0:
1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
every authenticated page (and left the page title empty). R12 missed it: the page still
returned 200 with no console error and the <title> comes from the Livewire component. Fixed
the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.
2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
(withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
anyone holding the bundled app key — including over a stale Caddy host after a domain change.
The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
channel is private.
Bump 0.4.0 -> 0.4.1; CHANGELOG.
Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A global command palette and keyboard shortcuts, mounted once in the persistent
layout with a single global keydown listener.
- Strg/⌘-K opens the palette: fuzzy-filter nav targets (German, from the sidebar) +
"Server hinzufügen"; ↑/↓ to move, Enter to run, Esc to close. Navigation uses
window.Livewire.navigate (SPA-fast).
- Leader "g" + key: d=Dashboard, s=Server, i=Dienste, f=Dateien, l=Audit-Log,
e=Einstellungen, y=System, v=Version.
- "/" focuses the page search ([data-page-search] on services/servers/audit) — but
only consumes the key when such a field exists, so the browser quick-find still
works elsewhere.
- "?" shows a shortcut-help overlay.
- Typing guard: shortcuts never fire inside inputs/textarea/select/contentEditable.
- Alpine destroy() removes the navigation listener (no leak across wire:navigate);
topbar gains a "Strg K" trigger button. New icons: command, corner-down-left.
R5/R3/R9 respected: the palette only navigates or opens the existing (R5) create-server
modal — zero direct destructive actions; tokens-only styling, German, no emoji.
Pint clean; Codex review clean (destroy cleanup + slash-passthrough hardened); R12 —
verified in-browser: Ctrl-K opens, filter works, Esc closes, g+f navigates, ? help,
HTTP 200, 0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardening checklist clashed visually (thick left border + filled status pill +
orange/ghost button on every row — too many competing colors). Redesign to a single
calm state signal:
- a leading lock glyph carries the state: closed/green = sicher, open/amber = offen
(new lock + lock-open Lucide icons);
- inline "sicher"/"offen" word next to the label + mono detail line; no left border,
no filled pill;
- the toggle button is neutral — secondary to make secure, quiet ghost to loosen —
so no accent/warning colors fight the state. Subtle row hover added.
R15: `codex review --uncommitted` → clean (cosmetic, hardening-toggle behavior
preserved). R12: server detail 200 / 0 console errors; 3 sicher / 3 offen render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security pivot — make server hardening, SSH access, firewall, dashboard domain/TLS
and the release channel controllable from the dashboard (no SSH needed), with guards
so a remote change can never lock the operator out.
Foundation
- ssh_credentials gains name + disabled_at + last_used_at; CredentialVault refuses a
disabled credential. New key/value Setting model. FleetService::runPrivileged() /
runPlain() — central sudo-aware exec (base64-wrapped sh -c), live-verified as root.
A · control-plane self-hardening
- SecurityHeaders middleware: env-aware CSP (allows the Vite dev origin in dev, strict
same-origin in prod), X-Frame-Options DENY, nosniff, Referrer/Permissions-Policy,
HSTS when secure. 2FA brute-force throttle (5/60s). install.sh sets
SESSION_SAME_SITE=strict + EXPIRE_ON_CLOSE + SECURE_COOKIE (only behind TLS).
B · SSH credential management
- name/label on the access; a credential card on the server page with Bearbeiten /
Sperren-Entsperren (kill-switch) / Löschen (R5), all audited.
C · server hardening from the dashboard (guards + confirmation)
- HardeningService (PermitRootLogin no, PasswordAuthentication no, fail2ban,
unattended-upgrades) + FirewallService (UFW). HardeningAction modal previews the
exact root commands before applying. GUARDS: refuse to disable password-login when
Clusev itself logs in by password or no key exists; UFW opens the real sshd port +
80/443 before enabling. Live-verified non-destructively (previews, the password
guard refusing, ufw status read).
D+E · System page (/system)
- Dashboard Domain + Let's-Encrypt email (Setting) -> DeploymentService renders the
matching Caddy site block (honest: stages the file + shows the reload command, never
fakes TLS). Release channel (stable|beta|dev) configurable; Versions reads it.
Built largely by 4 parallel agents into disjoint files; shared files integrated + the
security-critical bits hardened by hand (the password-auth lock-out guard + env-aware CSP).
Verified (R12): /system + server detail + all 8 routes 200 / 0 console errors (CSP does
not break Livewire/Alpine/Vite); credential card + 5 hardening "Anwenden" buttons render;
the hardening modal opens with the command preview; System persists domain/channel +
renders valid Caddy config; runPrivileged runs as root + the vault refuses disabled creds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Service control now works with a sudo-PASSWORD credential (not only NOPASSWD):
FleetService::sudoFn() defines a remote priv() that feeds the vaulted password to
`sudo -S` over stdin (base64 over the encrypted channel, never on the argv) when not
root, else falls back to sudo -n. Verified LIVE on 10.10.90.162: cron restart/stop/
start executed for real (ActiveEnterTimestamp advanced to now, is-active=inactive
after stop, restarted clean), journal reads real entries via sudo.
- Login brand panel: removed fabricated telemetry — fake "24 Hosts erreichbar",
"cpu 31% mem 48% load 0.86", "clusev connect 10.10.90.0/24". Replaced with true
capability lines (agentless SSH/phpseclib, TOFU host-key pinning, 2FA, audit log,
AGPL). Only real claims now (per "nur eintragen was auch wirklich geht").
- New Version & Releases page (/versions, EN route per R13) with REAL data only:
version from config/clusev.php, build SHA + branch read from .git at runtime,
changelog parsed from a real CHANGELOG.md, real Gitea repo + AGPL license, honest
update path (deploy commands) — NO fake updater / stars / forks / CVEs / "update
available v2.5.0". Sidebar nav + tag/git-branch icons added.
- Dummy-data sweep (12-auditor workflow + adversarial verify): 1 confirmed finding —
removed the unused sine/cosine series() fake-sparkline generator in Dashboard.php;
also fixed a stale "static seed" chart comment and a stale "mock listing" comment;
derived the versions repo label from config (DRY). 5 false positives dismissed.
Verified (R12): /login + /versions + all 7 routes 200 / 0 console errors; login
fake-metrics gone + honest lines + fonts load; versions shows real version/build/
changelog/repo with zero fabricated stats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix Alpine "Invalid or unexpected token" on file/service/key actions: @js()
does NOT compile inside a Blade component (x-btn) attribute, so a literal
@js(...) reached the DOM and Alpine choked on it. Switched all 10 wire:click
arguments to loop-index lookups (files: open/go/download/edit/confirmDelete;
services: confirm; keys: confirmKeyRemoval) — the component resolves the value
server-side by index. {{ $loop->index }} compiles in component attributes; @js
does not.
- Self-host fonts (R14): Chakra Petch / Space Grotesk / JetBrains Mono as local
.woff2 in resources/fonts/, declared via @font-face in app.css, Vite-bundled
with relative urls so they resolve in dev AND the prod build. No Google Fonts
/ CDN link or @import. New rule R14 added to rules.md + CLAUDE.md.
- Auth redesign (split-brand, per reference): new auth layout with a brand panel
(faux terminal + glow mesh, hidden below lg) + redesigned login,
two-factor-challenge, two-factor-setup, password-change forms.
- x-btn: add size="lg" (h-11, >=44px touch target) for the full-width auth CTA;
one shared button component, no bespoke styles.
- Remove unused Laravel welcome.blade.php (was full of raw hex — R3 cleanup).
Verified (R12): /login 200 / 0 console errors, fonts load (Chakra+Grotesk+JB),
brand panel visible @1440; file-editor REAL click -> modal opens, textarea
renders, 0 Alpine errors; editor load/save roundtrip (/etc/hostname='debian');
all 7 routes 200 / 0 console errors. Full rules audit R1-R14 clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full feedback round — all browser-verified (R12: 7 routes 200, zero console errors)
and functionally tested live against the target host.
- Metrics are real AND honest: poller now stores cpu/mem/DISK/LOAD history (all
four sparklines real, not synthetic); KPIs show absolute context (Memory
"1,9 / 7,8 GB", Disk "13 / 80 GB", Load "4 Kerne"); tiles are status-coloured
and Load is rated against core count (so "yellow" actually means load≈0.7-1.0×
cores), not a fixed warning tint.
- File manager is functional: Hochladen (Livewire upload -> SFTP put), Download
(SFTP get -> streamed), and view/edit (new FileEditor modal: SFTP get, binary/
size guards, SFTP put on save + audit). File names + Bearbeiten open the editor.
- Settings redesigned (/frontend-design): account identity header + section nav
(Profil / Sicherheit) instead of flat stacked panels.
- Routes are English (R13): /einstellungen -> /settings; rule added to rules.md +
CLAUDE.md. Dummy data removed: the topbar "Flotte online / Uptime 42d" + dead
bell button are gone, replaced by a real "<online>/<total> online" pill.
New: app/Livewire/Modals/FileEditor + view; FleetService get/read/write/upload +
Sftp putFromFile/size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round of UX work from user feedback (browser-verified per R12 — all routes 200,
zero console errors).
- Server-Details redesign (/frontend-design): hero header band (status-tinted
server glyph + name + meta strip + actions), a full-width row of dense vital
cards (ring + label + absolute used/total) instead of 3 donuts floating in a
height-stretched panel, hardening checklist with status-colored accent borders,
and a clear section hierarchy (vitals → specs+security → volumes+net → keys).
- New user Settings page (/einstellungen): profile (name/email), password change
(current-password gated), 2FA status + enable link / disable (confirm + audit).
Sidebar "Konto" nav group + clickable user block.
- SSH key generation: "Neues Paar generieren" in the add-key modal makes an
ed25519 keypair (phpseclib) — public key installed on save, private shown once.
- Services list is height-capped + scrollable so the Journal below is reachable.
- <x-btn> now also renders <a> (href) for link-buttons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The modal panel rendered *behind* its own backdrop (it "appeared then vanished"):
Tailwind v4 no longer turns `transform` into a stacking context, so the static
panel sat under the fixed/blurred backdrop. Fixed with `relative z-10` on the
panel (interactively verified by force-opening the modal).
- New <x-btn> component: one compact, consistent style (h-8 text-xs) with
variants (primary/accent/secondary/danger/ghost/ghost-danger). Replaced every
ad-hoc action button — modals, service start/stop/restart, file row actions +
upload, server-details (Zugang/Dateien/key add+remove). No more oversized,
inconsistent buttons.
- New EditCredential form modal + "Zugang" button on Server-Details: deposit or
update a server's SSH login (e.g. root) — this is where the privileged
credential for systemctl/journal gets stored (encrypted vault). Changing it
re-pulls the snapshot with the new login.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route switching was slow because each page did its SSH reads inside mount(),
blocking the response. Now mount() only sets cheap shell state and the SSH read
runs in load() via wire:init — the page shell + skeletons render instantly and
content streams in.
- Lazy-load Dashboard (services), Services, Files, Server-Details: SSH moved from
mount() to load(); skeleton placeholders (new <x-skeleton>) while !$ready.
- Modal container: z-10 -> z-50 (was rendering *under* the z-40 sidebar) and a
real max-w-lg (the dynamic modalWidth classes were never generated by Tailwind,
so modals spanned full width). This also fixes the "flash + disappear".
- Modal/action buttons: normalize size (min-h-11 uppercase font-display -> h-9
text-sm font-medium) and add a wire:loading spinner + disabled-while-running on
the confirm/save actions (no double-submit, clear feedback).
- Server-Details gauges keep wire:poll (live) but no longer block initial render.
Verified: every page renders ready=false + skeleton with no SSH, then load()
populates real data; modal buttons carry the spinner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses "chart/gauges don't update" and "design": the visible live layer no
longer depends on the Reverb WS path (which was unreliable through the dev NAT).
- Poller is now the single SSH metrics source: FleetService::applyMetrics writes
the latest reading + a rolling cpu/mem history into the cache (and the DB).
- Dashboard reads latest + history from cache (no SSH on web render); the big
chart and KPI sparklines render real history; wire:poll.10s refreshes them.
- Server-Details gauges read the poller-updated row via wire:poll.10s; the donut
rings get a size="lg" variant (h-24) so they read clearly + animate.
- FleetSeeder no longer seeds a fake fleet (those servers had no credentials and
showed no data). It seeds one real server from CLUSEV_DEMO_SSH_* env vars, or
an empty fleet otherwise. Existing fake servers removed from the demo DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a generic ConfirmAction modal (LivewireUI\Modal\ModalComponent). On confirm
it persists exactly one AuditEvent and re-dispatches a page event so the origin
applies its own state change — domain-agnostic and reused everywhere (R5).
Wired the destructive actions, each writing an audit row:
- Services: start/stop/restart -> confirm + audit; service state reflects result
- Files: delete -> confirm + audit; entry removed
- Server-Details: revoke SSH key (new per-row trash button) -> confirm + audit
(carries server_id)
Supporting changes:
- Publish + restyle the modal container for the dark theme (void backdrop +
surface panel + shadow-pop instead of the package's gray/white defaults)
- Toaster island in the app layout that catches the `notify` browser event
- Add alert/power/rotate/trash icons to x-icon
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the dashboard to the BASTION reference (clusev-template) — the active
server's detail view:
- x-metric: KPI tiles with sparklines + trend (CPU/Memory/Disk/Load).
- live dual-series chart (CPU+MEM, grid + Y/X axes + legend) as a dualChart
Alpine island; MetricsTicked + clusev:mock-metrics now broadcast CPU+MEM.
Static SSR paths remain as the no-JS fallback.
- systemd services as a table.
Tokens/currentColor only — no inline styles; Y-axis via utilities.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- WithFleetContext concern: resolves the active server (session, default first
online) + the fleet.
- ServerSwitcher Livewire component in the persistent sidebar: dropdown of the
fleet; selecting persists active_server_id in the session and re-navigates so
panels reflect the choice.
- Dashboard reacts: resource rings + panel subtitles follow the active server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>