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>
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>
On the Server-Details security section, a single installed tool was capped at
lg:max-w-2xl and left-aligned, leaving a large void to its right. Now a lone
panel spans the full content width and splits its own body into two columns on
lg ($lone flag): firewall = defaults/state | rules list; fail2ban = status
summary + view-bans | whitelist (ignoreip). Two tools keep the side-by-side
lg:grid-cols-2 grid. All wire:click / $dispatch bindings, buttons, and __() keys
preserved; no new lang keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The System page was just constrained to max-w-3xl — narrower, not designed.
Rework it into an intentional composition at max-w-5xl:
- Critical alerts (restart-required + bare-IP) are now full-width banners
above the working area, not buried in stacked inset blocks.
- A 2-column lg grid separates "do" from "know": left/main panel holds the
actionable form (domain input + Speichern, TLS-mode selector); right/side
panel is the status & recovery stack (access address, TLS state, SSH reset).
- Release-Kanal stays a full-width band below. Everything stacks to one
column on mobile.
- Header HTTPS/Plaintext pill made more prominent; domain Save promoted to
the primary action.
All wire bindings (confirmDomain, confirmTlsMode, restartNow, restartPending/
restartRequested) and every __() key preserved; two new section-label keys
(section_config, section_status) added with DE/EN parity. Tactical-Terminal
tokens only, no inline styles, existing component kit reused.
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>
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>
Login + password-change + reset + forgot get an eye toggle. Email fields switch to
type=text so validation lives only in the Livewire class (consistent custom messages).
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>
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>
The livewire:navigated activate-POST hook introduced unbounded cross-request
ordering races (the on-screen confusion is already fixed race-free by
Show::mount setting the active server + ServerSwitcher::targetFor navigating on
switch). Remove the JS hook + activate endpoint. The remaining bfcache edge is
invisible (the cached page shows the right server) and a transient stale session
resolves on the next deliberate navigation — documented in the handoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wire:navigate restores Back/Forward pages from its client cache without re-running
Show::mount(), which could leave active_server_id pointing at a different server
than the details page on screen. Add a servers.activate endpoint + a global
livewire:navigated hook that re-asserts the on-screen server on every navigation
(including cache restores).
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>
Both reset paths flash a `status` and redirect to login, but it was never
rendered. Show it as a success banner so the user gets confirmation their
password changed.
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>
Responsive audit (R7) at 375/768 — all pages were overflow-free with current
data, but three elements overflow with long data the live fleet doesn't have:
- system: the panel URL link (domain up to 253 chars) had no break-all, and the
access-address dd had truncate without min-w-0 (so a flex child won't clip).
Add break-all to the link, min-w-0 to the dd, shrink-0 to its label.
- servers/show: the fail2ban banned-IP span had no min-w-0/break, so a banned
IPv6 pushed the row past 375px. Add min-w-0 break-all + shrink-0 on the button.
Verified by injecting a 100-char domain and long IPv6 into those elements at
375px: document no longer overflows (was 1685px → 375px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Codex review of the panel-grid + pending-status work:
- Firewall/fail2ban services report installed=false together with readError=true,
so gating panel visibility on `installed` alone hid the read-error message and
made a failed probe indistinguishable from an absent tool. Show the panel when
installed OR readError.
- The server-details hero only mapped online/warning/offline, so a freshly
created `pending` server rendered an untranslated "Pending" label and an
uncolored icon. Add the localized cyan pending state to $statusLabel + hero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a muted one-line hint on the SSH-Passwort-Login checklist row while
password auth is still on, so the operator knows access becomes SSH-key only
(and to deposit a key first) before toggling — mirroring the lockout guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each panel renders only when its tool is actually installed (the Sicherheit
checklist carries the install/Aktivieren path otherwise), removing the redundant
"nicht installiert" boxes. Both shown -> 2-col responsive grid (lg, items-start);
one shown -> full width. Trash uses danger-soft, unban uses secondary.
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>
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>
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>
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>
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>