The 2fa.codes_fresh reveal flag is now a timestamp (set at enrollment) and
RecoveryCodes::mount() only reveals within [now-600s, now], symmetric to the
download-grant window. A reveal flag whose modal never opened can no longer
re-reveal the codes later in the session. FirstFactorCodesTest asserts the
flag is an int.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hardening features completed in parallel (each with its own spec + tests),
sharing call sites (TwoFactorSetup, WebauthnKeys, routes) so committed together:
- ConfirmAction token hardening: destructive confirm flows now carry a server-issued,
single-use, signed token (App\Support\Confirm\ConfirmToken) instead of trusting a
client-mutable event/params/auditTarget. Every #[On] apply handler consumes + validates
the token (forged/replayed/direct-bypass calls no-op). Server-scoped per action; closes
the codebase-wide confirm-bypass + audit-forgery vector across Security, Sessions, Users,
WebauthnKeys, Servers\Show, Services, System, Files.
- Backup-code airtight reveal: codes are revealed via a transient channel, not a persisted
Livewire property, so a captured/replayed snapshot can't re-render them; the recovery
download is grant-gated.
Full suite green (162).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stored recovery codes were re-viewable two ways after their one-time
reveal. Both paths are now closed so codes are only viewable/downloadable
right after a fresh generation (enrollment or regenerate).
HOLE 1 — RecoveryCodes::$revealed was a mutable public Livewire prop, so
a crafted /livewire/update could flip it to true and make render() emit
stored codes. Annotated with #[Locked]; Livewire now rejects any
client-side write (set still happens server-side in mount()/regenerate()).
HOLE 2 — two-factor.recovery.download was auth-gated only, so any authed
user could GET it anytime to re-download stored codes. Added a one-time
session grant (2fa.download_grant) put alongside the existing
2fa.codes_fresh wherever codes are freshly generated (TwoFactorSetup,
WebauthnKeys, RecoveryCodes::regenerate); the route now
abort_unless(pull(grant), 403) before streaming — one download per fresh
generation, later direct hits 403.
Tests: lock rejection + download-grant lifecycle (forbidden → granted ok
→ consumed forbidden). Full suite 137 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backup (recovery) codes must be viewable ONLY at the moment they are
generated — at first-factor enrollment or on regenerate — and NEVER
re-viewable afterward from "Backup-Codes verwalten".
- Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a
one-time session flag (2fa.codes_fresh); regenerate() sets it. render()
emits codes ONLY when revealed, so a client cannot tamper a flag to
re-view stored codes.
- View: revealed state shows codes grid + download + warning + close;
hidden state shows a security notice + Neu erzeugen + close (no codes,
no download).
- TwoFactorSetup::confirm() and WebauthnKeys::register() put
2fa.codes_fresh on first-factor code generation (put, survives the
redirect + later openModal request).
- Add recovery_hidden_notice (DE/EN parity).
- Tests: manage view hides codes; fresh flag reveals once; regenerate
reveals the new set; first-factor paths set the flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Gesperrte IPs ansehen" modal did its SSH Fail2banService::status read
in mount(), so clicking the trigger blocked for seconds with no spinner
before the modal appeared.
Split mount() into an instant mount() (stores serverId only) plus a
load() method fired by wire:init, mirroring Services\Index. While
! $loaded the view renders a 4-row skeleton inside the max-h-96 scroll
container; once loaded it renders the real jail/IP list (or empty/error).
The unban flow still re-reads via loadJails() and dispatches
fail2banChanged, keeping #[Locked] $serverId and the audit intact.
Adds Fail2banBansModalTest covering the instant mount (status never
called), load() rendering the list, read-error surfacing, and both
unban success (audit + refresh + dispatch) and failure paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex P2: concurrent deletes could both pass the >1 check. Now the count + delete run in
one transaction under a row lock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening an image now renders it (SFTP base64 data-URI, 5 MB cap, save disabled for
images) instead of the text editor. Recovery-codes modal buttons realigned + the
close button relabeled 'Schließen' (it only closes). All modal textareas resize-none.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fail2ban-Status panel listed every jail + banned IP inline (very long). Now a
compact summary + 'Gesperrte IPs ansehen' opens a scrollable modal (Fail2banBans)
that reuses Fail2banService::status/unban and dispatches fail2banChanged. serverId
is #[Locked].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-applies the onboarding gate to /livewire/update component calls, not just the
initial page GET, so a component mounted while onboarded cannot run destructive
actions after the account is reverted to must_change_password mid-session.
Addresses the codebase-wide Codex finding flagged during the SSH-key-provision review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guard the TOTP verifyKey call behind hasTotp() so a key-only user's null
two_factor_secret never reaches Google2FA::verifyKey(); they reset via a
backup code. Also adds the reset_no_2fa_note lang key (EN + DE) rendered
on the forgot-password form explaining the no-2FA recovery options.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TwoFactorSetup: move to app layout (hosts wire-elements/modal), guard on hasTotp() not hasTwoFactorEnabled(), redirect to settings and flash open_recovery_modal on first TOTP enrollment
- WebauthnKeys.register(): drop hasTwoFactorEnabled() guard (first key may be the sole factor), generate codes + dispatch openModal on first key enrollment
- Settings\Index: add #[Url] on $tab, add $openRecoveryModal flag, mount() reads the flash and lands on the security tab
- settings/index.blade.php: x-init block dispatches openModal when $openRecoveryModal is set
Co-Authored-By: Claude Sonnet 4.6 <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>
Revert the hydrate-on-every-request sync — writing the single global active-server
session on every async request (slow load(), polls, parallel tabs) is inherently
last-writer-wins and races. Setting it only at mount (once per deliberate
navigation) is race-free and fixes the visible drift. bfcache restore + multi-tab
remain inherent edges of the single-session model — documented in the handoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a hydrate() hook so Show re-asserts its server as active on the wire:poll tick
and any action — covering a wire:navigate bfcache restore that skips mount(),
without the earlier racy client-side POST. Idempotent + server-side, no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two "current server" notions diverged: the sidebar switcher (session active_server_id)
vs the server-details URL. Now viewing a server's details sets it active (sidebar
always matches the page), and switching the sidebar while on a details page navigates
to the chosen server (instead of reloading the old one). One current server, in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ZeroTolerantCounterChecker only tolerates a 0 counter when the stored counter is
also 0; a 0 after a non-zero counter is a rollback and is rejected.
- clusev:reset-admin --disable-2fa now also deletes the user's security keys, so a
compromised key can't revive when 2FA is re-enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Swap the strict counter checker for a zero-tolerant one so platform
authenticators / synced passkeys (which always report counter 0) can assert,
while non-zero counters still must strictly increase (clone detection).
- Guard registration (options + register) and the UI on hasTwoFactorEnabled, so a
key is never created against a disabled/reset second factor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- credential_id can exceed the unique-index key length; store it in TEXT and
enforce uniqueness on an auto-derived sha256 hash column (lookups use the hash).
- Disabling 2FA now also deletes the user's security keys + backup codes, so
re-enrolling starts clean and old factors never silently revive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
options() now validates the label before returning creation options, so clicking
Add with a blank/invalid name aborts before navigator.credentials.create() and
never leaves an orphaned credential on the authenticator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist the updated PublicKeyCredentialSource (not just the sign_count column)
so the next assertion validates against the latest counter — keeping the
library's cloned-authenticator / counter-rollback detection effective.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebauthnService builds creation/request options (rpId = active domain, ES256/RS256,
attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the
challenge in the session, and verifies attestation/assertion responses (allowed
origin = https://<rpId>, sign-count tracked). Option building + serialization are
unit-tested; the cryptographic verify path is browser-verified on a domain host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebAuthn is offered only with an active domain (the rpId) over HTTPS; bare-IP/HTTP
returns false so a ceremony never runs with an invalid IP rpId.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review follow-ups: extract the notify toaster into a shared partial and
include it in the auth layout too, so sendResetLink + recovery-code regenerate
actually surface their confirmation (the auth layout had no toaster). Record an
AuditEvent for password resets via the email-token path, matching the 2FA path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Codex security review:
- ForgotPassword + clusev:reset-admin now rotate remember_token, so a stolen
remember-me cookie cannot survive a password reset (the email-token path
already did this).
- useRecoveryCode reads/checks/removes under a row lock (DB transaction +
lockForUpdate), so two concurrent requests can't both spend the same one-time
code (replay).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell-only emergency recovery: resets the admin password (prints a random one if
omitted) and, with --disable-2fa, clears the 2FA secret + backup codes so the
operator can re-enroll. Requires server access — the safe last resort.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Passwort vergessen?" on login -> a reset view that proves identity with a TOTP
or one-time backup code (no email needed), then sets a new password under the
min(12) mixedCase numbers policy. Rate-limited, generic messages (no account
enumeration), audited.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CSRF (419), unmatched-route 404 and maintenance (503) errors are thrown before
the web-group SetLocale middleware runs, so the custom error pages rendered in
the default locale for EN users. Extract SetLocale::apply() (session-guarded, so
it is safe pre-session) and call it from a withExceptions render hook that falls
through to the normal renderer — the error views now honour the user's language
where it is known.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding assets (public/, served directly):
- favicon.svg (scalable, primary), favicon.ico (16/32/48 PNG-embedded),
apple-touch-icon.png (180), icon-192/512.png, site.webmanifest. The mark is
the in-app brand glyph (orange Lucide "server" on dark). Wired into both
layouts via a shared partials/head-icons (rel=icon svg+ico, apple-touch,
manifest, theme-color #06080A). nginx serves the manifest as
application/manifest+json.
Never show raw error text (custom messages only):
- Custom branded resources/views/errors/{403,404,419,429,500,503}.blade.php on a
self-contained error layout (design tokens, brand mark, bilingual via
lang/errors.php) — replaces Laravel's default Symfony/whoops pages.
- docker-compose.prod.yml pins APP_DEBUG=false so a prod error can never render a
stack trace (the custom pages show instead).
- Files download error dispatched a bare $e->getMessage(); wrap it in the
localized files.download_failed (consistent with upload/delete).
Verified in-browser: icon links present + all assets 200; custom 404 renders the
branded German message (no raw "Not Found"/trace), responsive at 375, 0 console.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auth success alone does not prove the server can run commands (a server that
authenticates but rejects exec channels would be accepted, yet every fleet
operation runs commands). The probe now echoes a unique marker and requires it
in the output — proving exec works, while still tolerating servers that omit
the optional exit-status packet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
testConnection gated success on `exit code === 0`, but phpseclib returns false
from getExitStatus() when a server omits the optional exit-status message, which
would reject a valid login with an empty error. A successful connect() already
proves the credential (it throws on auth failure), so the probe now succeeds on
any exception-free connect + exec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateServer now probes the entered SSH login inside the create transaction; a
failed probe throws and rolls back (no half-registered server), surfacing the
SSH reason on the form. Successful creation starts the server as `pending`
("Initialisierung") instead of red/offline until first contact promotes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connect + trivial exec against a server's stored credential; never throws,
returns {ok,error}. Used by the create-server guard to verify the SSH login
before persisting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Automatic updates are a choice, not a security gate: the unattended row now
reports secure=true always and carries a `neutral` flag. The checklist renders
it muted (aktiv/inaktiv) instead of the green/orange SICHER/OFFEN verdict, so
"off" is never flagged as insecure. Detection is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardening checklist read "installed" from the package manager alone
(dpkg `^ii` / rpm `-q`), so it disagreed with the live fail2ban/firewall
panels, which use `command -v` + `systemctl is-active`. On a host where the
component is genuinely present but not recorded as an apt `^ii` package —
installed from source, or left in dpkg `rc` (config-only) state — the
checklist showed `nicht installiert` while the panel reported it active with
real bans (the reported contradiction).
The installed probe now ORs three conditions: the package-manager test, the
client binary on PATH, and the service already running. Since a component can
never be active without being installed, this only removes FALSE negatives —
it can never produce a false positive. Detection is now consistent with
Fail2banService::status and FirewallTool. Applies to both fail2ban and the
firewall (ufw/firewalld).
Adds HardeningServiceTest covering the Debian/apt source-install fallback:
the emitted probe gains `command -v fail2ban-client` and `dpkg -l ufw`, and an
active component reads as featureOn.
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>
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.
How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
DIRECTLY (cache-independent) and retries until the settings table is readable; if it
never is, it freezes the env fallback so the active domain is always a FIXED value
(never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
(/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
command; DE/EN strings (R16).
Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
always a recovery path. trustProxies('*') only in production (dev can't be tricked into
faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
and IP-recovery redirects stay on the IP.
Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).
Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per operator feedback ("das soll automatisch sein, die Caddy-Config muss man nicht
sehen, nur 'SSL steht'"):
- TLS is fully automatic via Caddy (auto-issue + auto-renew + HTTP→HTTPS). The System
page now shows only the STATUS (domain + access URL) — the generated Caddy config and
the manual `caddy reload` command are gone, and the domain/email edit form is removed.
- The panel domain is an INSTALL-TIME value (APP_DOMAIN → config('clusev.domain')), so it
stays consistent with APP_URL, the Reverb (wss) endpoint and cookie security — none of
which can be re-derived at runtime (Caddy's site address + the browser's Reverb host are
fixed at install/build, and .env is never rewritten). DeploymentService is now read-only.
- Removed AppServiceProvider's runtime app.url override (it let a stale DB value shadow the
install-time URL). Migration drops the inert empty dashboard_domain/dashboard_email rows
(a real configured value is preserved as history; re-apply via install.sh — see CHANGELOG).
- Hardening checklist: all toggle buttons share one style (secondary/bordered) instead of
varying with state (R10).
Bump 0.2.0 -> 0.2.1 + CHANGELOG. Pint clean; Codex reviewed (the remaining note is a
deliberate install-time-domain tradeoff — a runtime/DB domain cannot be served by the
templated Caddy without a false "TLS active" status); R12 — system/server-show/dashboard
HTTP 200, 0 console errors, raw config + edit form gone, buttons uniform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses "ich sehe nicht welche IPs gebannt sind / entsperren / whitelist". The
server-detail page gains a "fail2ban-Status" panel.
New Fail2banService (owns all fail2ban I/O):
- status(): jails with currently/total failed+banned + the banned IP list, plus the
whitelist; sentinel-guarded, and a CLIENT_OK marker so an active service whose
fail2ban-client query fails is reported as a read error, not "0 jails".
- unban() (idempotent: "not banned" = success), manual ban() GUARDED against loopback
(whole 127.0.0.0/8 + ::1) and Clusev's own SSH source (canonical inet_pton compare).
- readConfig()/writeTuning()/writeIgnoreip(): tuning and whitelist live in SEPARATE
zz- drop-ins and each writes only its own keys, so a whitelist change can never
rewrite the ban policy (and vice-versa). The legacy single-file zz-clusev.local is
removed on tuning save (migration). ignoreip is preserved VERBATIM (hostnames/CIDRs,
continuation lines) — loopback always re-seeded; add/remove report no-ops so the
audit only records real changes.
UI: jails + banned IPs each with "Entsperren" (direct, audited), whitelist editor
(add/remove, audited), "IP sperren" via an R5 modal (modals.fail2ban-ban). Jail/IP
args are Js::from()-encoded (remote-sourced, injection-safe). Direct handlers catch
SSH failures. Tuning modal migrated to Fail2banService.writeTuning.
MaintenanceService slimmed to package updates only (fail2ban moved out).
Pint clean; Codex review clean (IPv6 guard, verbatim ignoreip, decoupled drop-ins,
legacy migration, read-error detection all hardened); R12 — server-detail HTTP 200,
0 console errors, fail2ban panel + jail + whitelist render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-detail page gains a "Firewall-Regeln" panel — addressing "ich kann nur
aktivieren, sonst nichts": list rules, add, and delete, with hard SSH-lockout guards.
FirewallService:
- status(): one privileged read — installed/active, default policies, and the rule
list. ufw rules come from `ufw show added` (add-syntax, works active OR inactive),
defaults from /etc/default/ufw; a CLUSEV_FWREAD_OK sentinel + a "Status:" check
distinguish a real read from a failed/empty one.
- addRule(): validated action/proto/port/from (proto before `from`, ufw grammar);
refuses deny/reject on the SSH port or portless (would block all inbound).
- deleteRule(): whitelists the spec against rules ufw actually reported (injection-
proof — a forged spec can't match), deletes by spec (race-free, no rule number),
guards SSH-port allow rules incl. ranges and trailing comments, distinguishes
not_found from a real deletion so the audit only records true deletions.
firewalld is READ-ONLY this release: status() reads the runtime state of every active
zone (ports/services/rich rules, zone-attributed), rule mutation is refused with a
German note. on/off + hardening (Phase A) still work.
UI: x-panel with default-policy badges, "+ Regel" (modal modals.firewall-rule), per-
rule delete via R5 ConfirmAction (audit deferred to the real outcome), graceful
read-error / not-installed / inactive / firewalld-read-only states.
Fix: the firewall row's tone variable was renamed $tone -> $ruleTone — the page-level
$tone is a closure used by the gauges/Volumes panel; reusing the name clobbered it and
500'd the page below the firewall block. R12 now confirms the full loaded page.
Default-policy EDITING was intentionally not exposed (highest lockout risk).
Pint clean; Codex review clean; R12 — server-detail HTTP 200, 0 console errors,
firewall panel + rules + volumes all render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation so Clusev no longer assumes Debian/apt/ufw/systemd. A host's OS is
resolved once (cached) and every package/firewall/service decision asks it.
New app/Support/Os/:
- OsProfile — family + package manager + firewall tool + service manager, plus
supports(feature) returning NULL or a German reason (graceful degradation).
- OsDetector — one unprivileged probe (/etc/os-release + `command -v` with sbin on
PATH + which firewall is RUNNING); family from ID/ID_LIKE cross-checked against
installed binaries; cached 1h (60s when nothing detected). Readability-guards the
`.` source so a host without /etc/os-release still falls back by package manager.
- PackageManager — apt/dnf/zypper command strings (pending count, apply, install,
is-installed); zypper exit 102/103 normalized to success.
- FirewallTool — ufw + firewalld enable/disable. firewalld opens ssh/80/443 in the
PERMANENT config BEFORE the daemon starts filtering (handles running + stopped),
preserving the no-lockout guard for custom SSH ports.
Integration:
- FirewallService enable/disable now OS-aware (ufw or firewalld) with a support gate.
- MaintenanceService: hasApt -> updateSupport(); pendingUpdates() + applyUpgrades()
across managers (dnf check-update exit 100 handled).
- HardeningService: state()/commandFor() per-OS (dpkg vs rpm, ufw vs firewalld, apt
periodic vs dnf-automatic; yum-only hosts gate auto-updates). Each row carries
supported/reason; unsupported features render muted with a German note instead of
a toggle. apply() refuses unsupported features gracefully.
- Server-Details surfaces the detected System / package manager / firewall.
- SystemUpdate modal + view: OS-neutral copy; audit action system.package_upgrade.
Arch (pacman) & Alpine (apk/OpenRC) are detected and gracefully disabled where
unsupported. Debian/ufw/systemd path verified behaviourally identical on the live
fleet. Pint clean; Codex review clean (fixed firewalld lockout + os-release guard +
yum/dual-firewall/zypper edge cases); R12 — 9 routes 200, 0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator feedback: no way to add servers; irrelevant storage-internals text; fail2ban
only on/off; raw shell commands shown; no apt update/upgrade.
- Add server: CreateServer modal (name, IP/host, SSH port, user, password|key, optional
label) on the Servers index → creates server + encrypted credential ATOMICALLY (DB
transaction). Strict IP/hostname validation (filter_var + hostname; rejects 999.x, ':').
- System updates (Debian/Ubuntu only): MaintenanceService + SystemUpdate modal — shows the
pending-update count (or "unbekannt" when undeterminable) and runs apt update && upgrade
as root; gated to apt hosts.
- fail2ban configuration: Fail2banConfig modal — Sperrdauer / Max. Fehlversuche / Zeitfenster,
written to a Clusev-owned jail.d drop-in (zz-clusev.local, last-wins; never touches the
operator's jail.local/jails). Durations kept verbatim in fail2ban's native grammar
(600, 10m, 1h 30m, -1). Reads the EFFECTIVE [DEFAULT] across files; refuses to save when the
current policy couldn't be read (no overwrite with unseen defaults); reloads fail2ban only
when already active (never starts it).
- Modals: removed the raw "Befehle (als root)" preview + raw stdout dumps from the hardening
modal — clean German description + result only.
- System page: dropped the .env/Datenbank storage-internals callout (irrelevant to the user).
R15 — Codex gate: `codex review --uncommitted` run iteratively; fixed every finding across 9
rounds (fail2ban jail clobbering, section/precedence, composite/-1 durations, reload-starting-
inactive, read-failure propagation, glob exit code, apt-count failure, atomic server create,
IP validation) until clean — 0 security issues throughout. Live-verified on 10.10.90.162;
R12: Servers/Detail/System 200 / 0 console errors, modals open, hardening modal shows no commands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the server-hardening UX per operator feedback ("only activate, can't
deactivate or adjust, UFW won't even activate, is the existing state read?").
- Bidirectional toggles: each item (SSH root-login, SSH password-login, fail2ban,
UFW, unattended-upgrades) is a clean Aktivieren/Deaktivieren toggle driven by the
live feature state, not a one-shot "Anwenden".
- Real state read (HardeningService::state, privileged): SSH from `sshd -T` (the
EFFECTIVE config — honours Include order + Match blocks), UFW from `ufw status`,
unattended from `apt-config dump` (effective periodic value), packages via dpkg —
so nicht-installiert / inaktiv / aktiv are detected; a feature counts as "secure"
only when installed AND active.
- UFW activation installs ufw if missing (fixes "ufw: not found"); opens the detected
sshd port + 80/443 before `ufw --force enable`; adds disable().
- SSH drop-in renamed 00-clusev.conf (sorts + wins first); apt periodic written to a
last-winning 99zz-clusev file. Long timeout for apt installs.
- preview() returns the EXACT command apply() runs (single source — no drift between
the confirmation preview and the executed mutation). Password-disable lock-out guard
intact (refused when Clusev uses password auth or no key exists).
R15 — Codex review gate: `codex review --uncommitted` run iteratively; fixed every
finding across 5 rounds (drop-in precedence, apt periodic config, preview/apply drift,
ufw status detection, privileged-read error handling, stale-config secure state) until
"no actionable regressions". 0 security issues throughout.
Live-verified on 10.10.90.162: state via sshd -T (effective), fail2ban + unattended
toggled on/off both ways, ufw installed + state reflects, lock-out guard refuses
disabling password auth. R12: server detail 200 / 0 console errors, toggles render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(hardening): fail2ban/unattended apt installs failed because the 12s SSH timeout
aborted the package download. runPrivileged/runPlain take a $timeout; apt actions use
600s. Live-verified: fail2ban installs + is active/enabled on 10.10.90.162.
- feat(release): cut the foundation as the first real semantic release v0.1.0 (no more
-dev). CHANGELOG is Keep-a-Changelog BY VERSION; the Versions page parses it by version,
reads the newest git TAG as "latest release", and honestly compares installed vs latest
in the channel. Channels reduced to stable|beta (no user-facing dev); System clamps a
legacy value back to stable. Tagged v0.1.0.
- fix(domain/.env): the panel domain lives in the DATABASE (Settings); the app never
rewrites .env. AppServiceProvider overrides config('app.url') at runtime from the Setting
(guarded against a missing table); /system states this explicitly. Caddy config is a
standalone generated file; a Caddy reload is the only infra step.
- docs(arch): Caddy is the prod-only TLS reverse proxy IN FRONT of the app container's
nginx (Internet -> Caddy:443 -> nginx:80 -> php-fpm); no Caddy in dev.
- R15 (rules.md + CLAUDE.md): Codex must review every change for errors + security before
"done". The Codex CLI is not installed in this runtime (needs `sudo npm i -g @openai/codex`
+ `/codex:setup`); an independent adversarial security review of this diff returned CLEAN
(no vulnerabilities, no bugs) as the interim gate.
Verified (R12): /system + /versions 200 / 0 console errors; channels stable|beta only,
DB-not-.env callout; versions by-version with v0.1.0; app.url override resolves from the
Setting; fail2ban live install ok.
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>
Service start/stop/restart now run over SSH (root direct, else `sudo -n`) and
report the real result instead of an optimistic state flip; the Services list is
reloaded afterwards to reflect the actual unit state. journalctl in systemd() is
likewise run via sudo/root so the full system journal is available once a
privileged credential exists. Unit names are validated before interpolation.
Without a privileged credential the action fails honestly ("sudo: a password is
required") rather than pretending to succeed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the panel actually operate the server, not just display it — for everything
that works in the SSH user's own space (no sudo required).
- FleetService: addAuthorizedKey (append, dedup, perms-safe via base64 transport),
removeAuthorizedKey (by SHA256 fingerprint, preserves every other key — never
rewrites blindly, no lockout), sshKeys (reload), deleteFile (SFTP unlink).
- AddSshKey form modal: paste a public key -> appended over SSH + AuditEvent +
list reload. Wired to the "Schlüssel hinzufügen" button on Server-Details.
- Server-Details: key removal now performs the real SSH removal then reloads;
new "Dateien" button opens the file manager scoped to this server.
- Files: delete performs the real SFTP unlink then reloads the directory.
Verified live: add+remove of a throwaway key leaves the existing key intact;
real file delete confirmed gone. systemctl start/stop/restart still need a
privileged credential (the demo account has no passwordless sudo).
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>
Resolve all 12 confirmed findings from the SSH-integration review.
Security:
- TOFU host-key pinning (new VerifiesHostKey trait + servers.ssh_host_key column):
SshClient/Sftp record the server host key on first connect and refuse the
connection on any later mismatch — phpseclib does not pin host keys itself.
- PollMetrics keeps one long-lived SSH connection per server and reuses it across
ticks (a single login, not one per interval) — avoids fail2ban/auth-log churn;
dead connections are dropped and re-established next tick.
- @js() escaping for every server-controlled value interpolated into wire:click
(file names, paths, service names, SSH-key comments/fingerprints) — prevents
JS-string breakage / injection from untrusted remote data.
Correctness:
- parseKeys regex makes the key comment optional (keys without a comment were
silently dropped).
- parseVolumes pops the three trailing single-token df fields and rejoins the
rest, so mount points containing spaces parse correctly.
- Sftp gains a disconnect() method to match SshClient (explicit cleanup).
Rules:
- Services "Start" -> "Starten" (R9, German).
- Files breadcrumb buttons get min-w-11 + padding (R7, >=44px touch target).
Verified live: host key stored + mismatch refused + correct key reconnects;
poller reuse polls ok; all pages still render real data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the page mock data with real reads from the target host via the
phpseclib SSH layer. New FleetService parses raw command output into the exact
shapes the Livewire pages already consume; all parsing forces LC_ALL=C and reads
/proc to stay locale-independent.
- app/Services/FleetService.php: metrics (cpu via two /proc/stat samples, mem via
/proc/meminfo, disk via df), full snapshot (identity/specs incl. virt+disk_gb,
volumes, interfaces from ip+/proc/net/dev, sshd/fail2ban/ufw hardening, authorized
keys), systemd units + journal, and an ls-based directory listing. One compound
command per read; connect/parse failures bubble up.
- clusev:poll-metrics command replaces the mock emitter in the dev supervisor:
polls every credentialed server, persists cpu/mem/disk/status, broadcasts
MetricsTicked(server) — unreachable boxes flagged offline, loop never dies.
- Pages wired with graceful failure (offline state, never a 500):
Dashboard (live cached metrics + notable units), Services (real units+journal),
Files (real listing + dir navigation via open/go/up), Server-Details (live
snapshot persisted onto the row + offline banner).
- WithFleetContext prefers a credentialed, non-offline server as the default.
- dualChart filters ticks by server name so the chart tracks the active host.
Verified live against a real Debian 13 box: metrics/services(91)/journal(25)/
files(navigable)/snapshot(5 ifaces, real hardening, real key) all parse correctly.
Credentials are stored encrypted in the vault — never in source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-command, idempotent production install per docs/install-update-design.md
(the "JETZT" phase). Onboarding (forced password change + 2FA) already ships
via EnsureSecurityOnboarded; the in-dashboard self-updater stays deferred to v1.x.
- install.sh: preflight -> idempotent secrets (set_kv never regenerates) ->
derive SITE_ADDRESS/APP_URL/REVERB_* from the single APP_DOMAIN knob -> build ->
up -> wait DB -> migrate + cache -> clusev:install. One-time admin password is
printed only on the terminal, never stored. Bare-IP and private-IP warnings.
- app/Console/Commands/Install.php: idempotent first admin (Str::password(20),
must_change_password); hard no-op once a user exists (INSTALL_LOCK idiom).
- docker/caddy/Caddyfile: one template, both modes; Reverb wss over /app/* and
/apps/* on the same address; admin-API-free, security headers. Validated for
bare-IP (:80) and domain (https) modes.
- docker-compose.prod.yml: Caddy is the only host-published service (80/443 +
HTTP/3); app/reverb/queue/mariadb lose host ports (internal net only);
CLUSEV_IMAGE indirection (locally-built tag, GHCR digest later); Redis requires
a password; caddy-data/caddy-config volumes persist ACME certs.
- .env.example: APP_DOMAIN/APP_SCHEME/ACME_EMAIL/SITE_ADDRESS/CLUSEV_IMAGE/
UPDATE_HMAC_KEY documented.
- README: real Clusev install/deploy guide (replaces Laravel boilerplate).
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>
- MetricsTicked broadcast event (channel 'metrics', as 'tick') + a
clusev:mock-metrics command (dev placeholder for the SSH MetricsPoller),
broadcasting a rolling CPU value every 2s.
- Echo + pusher-js client in app.js with an env-driven Reverb connection
(VITE_REVERB_*). metricsChart Alpine island seeds from server data, appends
each tick and redraws the sparkline; the indicator reflects the real WS state.
- Published config/reverb.php + config/broadcasting.php.
- Backend verified: the queue processes MetricsTicked every 2s with no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>