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>
Aborting a fetch can't stop a POST already running server-side, so a stale
activation could still land last. Chain the requests so each runs after the
previous resolves — the latest navigation's activation is always last. A 5s
per-request timeout keeps a hung request from stalling the chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Abort any in-flight active-server POST on each navigation so a slower stale
request can't overwrite the latest selection. Fix the activate-endpoint test to
use an onboarded user (the route is behind EnsureSecurityOnboarded).
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>
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>
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>
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>
iOS Safari zooms the viewport when a focused form control's font-size is < 16px.
The dense design uses text-xs/sm/[11px] on inputs, which triggered the zoom on
every input across the app. Add one unlayered media query forcing 16px on
input/textarea/select for touch-primary devices (@media (pointer: coarse)) —
covering phones AND tablets of any size (incl. 1024/1366px iPad Pro that a
width cap would miss) while keeping the small desktop sizing for mouse pointers.
Unlayered so it wins over Tailwind's utility classes.
Verified via touch emulation: inputs render 16px on phone/iPad/iPad-Pro widths
(matchMedia(pointer:coarse)=true) and stay 14px on mouse desktops at any width.
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>
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>
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>
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>
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>
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>
/services returned HTTP 500: @disabled() on a Blade *component* injects an
if/endif into the component tag and breaks compilation ("unexpected endif").
@disabled/@checked/@required only work on real HTML elements — switched to the
bound :disabled="…" attribute (the component attribute bag drops a false value).
Caught by a real browser probe (HTTP status + console errors per route), which I
should have run before. Documented that as R12 in rules.md + the CLAUDE.md verify
checklist: every touched page must load at HTTP 200 with zero console errors, in
its *loaded* (post-wire:init) state — a green Livewire::test is not sufficient.
Re-probed all six routes: dashboard/servers/detail/services/files/audit all 200,
zero console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>