Compare commits

..

447 Commits

Author SHA1 Message Date
boban 265136fc42 Metric history fix, service-failure audit trail, alert channels + secret-retarget hardening
Metric history: the per-minute sampler (clusev:sample-metrics) now reads
cpu/mem/disk from the `servers` DB row — the same source the live gauges read,
shared via MariaDB — instead of a cross-container Redis cache the poller may
not share in prod. It gates on last_seen_at freshness (skip offline/unpolled)
and keeps `load` as a best-effort cache read. History now fills whenever the
gauges have data, independent of the cache driver or container split — the
prod detail page no longer shows "Noch keine Verlaufsdaten" after hours.

Service-action failures are persisted: a failed systemctl start/stop/restart
writes a red service.<op>.failed AuditEvent with the FULL systemctl reason in
meta['output'] (mb_scrub-ed, 4000-cap), shown expandable in the audit log — a
truncated, ephemeral toast is no longer the only trace. The failure toast is a
short pointer to the audit log, level=error, and error toasts now linger 9s.

Alert delivery: a warning banner appears on the alerts page when NO channel
would reach anyone (no SMTP + no safe webhook + no Gotify). A new Gotify push
channel (server URL + encrypted app token) posts title/message/priority with
the token in X-Gotify-Key; a self-hosted LAN Gotify is allowed (scheme-checked,
not private-range-blocked like the SSRF-guarded webhooks) — a real push when a
service goes down, no e-mail needed.

Secret-retarget hardening (Codex-found class): changing a target while leaving
its secret blank could send the OLD secret to the NEW endpoint. Closed for the
Gotify token, the SMTP password (save + test-send + the runtime mailer), and
the host-terminal SSH credential (username/port change) — each drops/forbids
the stale secret before the new target is stored. Setting::uncached() reads the
security-sensitive keys DB-direct so a cache-repopulation race can't re-pair an
old secret with a new target. Three Codex rounds, clean; 778 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 22:21:54 +02:00
boban 182cb0fedd UI consistency batch: docker names, chart axis, empty states, control heights
Docker container display names drop the compose replica suffix ("clusev-app-1"
-> "clusev-app") in the host page and per-server tab; the real name/id still
drives every action/log call and shows on hover. The logs modal title now
shows the display name instead of the raw hex id.

Dashboard chart: the x-axis label row moves outside the relative chart wrapper
so the y-rail spans only the plot -> the "0" tick lands on the bottom gridline
instead of colliding with "-15 Min", and the 75/50/25 ticks align to their
gridlines.

Uptime + Certificates empty states become a centered icon-chip stack
(matching the threats/audit pattern) instead of a centered title with a
left-anchored hint.

Server-detail tab strip: overflow-y-hidden so the -mb-px overhang can no
longer make it scroll vertically; only the x-axis scrolls.

App-wide badge/button height sweep: x-badge gains a size prop (md = h-8) so
status chips that sit beside h-8 pills/buttons line up (SSH auth-type chip,
WireGuard SSH-lock, settings role). x-btn gains a "field" size (h-9, no coarse
min-h-11 bump) for form-row submit buttons that must match their h-9 input on
every device (audit/system/health/certs/groups/webauthn/commands). Topbar
Ctrl-K + language switch pinned to h-8; the triplicated pagination prev/next
buttons switched from py-sizing to h-11/lg:h-7 to match the page-number
buttons in the same row.

Codex-reviewed (one finding: h-9 class override lost to the coarse min-h-11
on touch -> fixed with the dedicated "field" size). 761 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:20:18 +02:00
boban 4248c83c42 Fresh-install bug batch: prod poller, installer UX, switcher/firewall polish
Prod metrics poller: docker-compose.prod.yml gains a `metrics` service
(clusev:poll-metrics --interval=15). The poller previously existed only in the
dev supervisor config, so on a customer install no process ever promoted a
server out of 'pending' (Initialisierung) or refreshed CPU/MEM — fresh
installs sat at 0% forever. The service inherits the hardened *image anchor.

Installer output: new run_logged() helper routes noisy steps (apt, docker
build/pull, compose up, migrate, caches) into install.log at the repo root —
TTY gets a spinner + "ok" lines, non-TTY gets start/ok lines, failures tail
the last 40 log lines and abort. Secret-bearing steps (APP_KEY, admin
password) deliberately stay out of the log. update.sh shares the same log via
CLUSEV_INSTALL_LOG and redirects git pull/verify-commit detail into it. The
log is initialized symlink-safely (mktemp + atomic rename -T) because the
repo root belongs to the unprivileged clusev user after install (Codex
finding); /install.log is gitignored so updates never conflict.

Server switcher: an empty fleet now renders an explanatory empty state with a
role-gated "Server hinzufügen" CTA instead of an empty dropdown panel.

Servers/groups headers: the raw <a> buttons (unconditional min-h-11) are
replaced with x-btn, aligning them with the h-8 accent button + status pill.

Firewall panel: the rules box is hidden for ANY inactive firewall (previously
only firewalld — an inactive ufw showed a misleading rules table via `ufw
show added`), with a tool-agnostic hint + Aktivieren action; the add-rule
trigger is gated on active. hardeningApplied now performs an in-place
security re-read (OS profile + hardening + firewall + fail2ban) instead of a
full-page skeleton reload, with a wire:loading cue and toggles disabled while
it runs — the state can no longer be re-toggled against stale data.
SshKeyProvision drops its redundant hardeningApplied (credentialChanged
already full-reloads).

Hardening modal: failure output limit 200 → 400 so the 202-char root-lockout
guard message renders unclipped; the root-login description now states that
PermitRootLogin no blocks both password and key logins.

Command palette: chords g o (Verfügbarkeit) + g b (Bedrohungen).

Codex-reviewed (one finding: symlink-following log init — fixed + re-verified
clean). 760 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:57:04 +02:00
boban b7e44ca3c2 UI polish + honest service-action reporting
Command palette: rebuild the nav list from a role-gated table so every
navigable page (Docker, Terminal, WireGuard, Commands, Security posture,
Updates, Certificates, Uptime, Alerts, Threats) appears with a role-gated
"g <key>" chord. The help panel is derived from the resulting list, so a
gated-out page has neither an entry nor a live shortcut (no 403 dead-end).
New chords k/t/w/c/p/u/r/a resolve via nav.find; no collision with the
hardcoded CMDK_GO map (d/s/i/f/l/e/y/v).

Scrollbars: add a .no-scrollbar utility and apply it to the server-detail
tab strip and the sidebar nav, so macOS "always show scroll bars" no longer
paints a permanent track where nothing overflows.

Threats KPIs: replace the orphaned 5-box row with a 4-box even grid of the
count metrics + a full-width Top-Attacker spotlight card (an IP, not a count).

serviceAction: a start/restart can EXIT 0 (systemd accepted the request) yet
the unit dies on launch (e.g. monit crashing on a bad config) -> is-active
reports failed. Verify the real post-action state and, when the unit did not
come up, report failure WITH the reason (systemctl status tail) so the
operator sees why instead of a false "gestartet". Harden all three systemctl
calls against argument injection: unit must start alphanumeric, passed after
`--` and escapeshellarg'd (Codex-reviewed, clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:15:57 +02:00
boban 40383261bd feat(ui): live sidebar badges (alerts / threats / update) without a refresh
The topbar online count + server-switcher poll, but the sidebar nav
badges (firing-alert "1", update chip) were static Blade — they only
updated on a page navigate/refresh, out of step with the live shell.

The sidebar can't itself be a Livewire poll (that would break its
request()->is() active-nav states). Instead: a tiny `navBadges` Alpine
island on the <nav> polls GET /shell/badges.json every 15s and updates
the [data-nav-badge] chips in place. The badge chips are now always in
the DOM (hidden at zero) so the poller can reveal/hide them without a
re-render. Counts come from the same cached+busted model helpers the
render uses (AlertIncident::firingCount / AuditEvent::threatLoginCount24h
/ ReleaseChecker::updateAvailable — all cache-only, no per-poll query
storm); the endpoint zeroes alerts/threats for non-manage-panel roles.

Verified live in a browser: with an incident fired mid-session, the alert
chip flips to "1" within one poll cycle with NO page refresh, zero
console errors. New ShellBadgesTest (auth + gating + count); the release
badge test updated for the always-present-hidden chip. 753 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:41:37 +02:00
boban d348e77ad7 fix(mail): force the logo size via inline CSS; brand header inside the card
Some clients (Apple Mail among them) ignore the width/height ATTRIBUTES
on embedded images and rendered the 192px CID logo at full size. Force
24px via inline CSS (width/height/max-*) as well, and move the brand row
inside the card as a proper hairline-separated header — tighter, tidier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:23:47 +02:00
boban da53b6f7c7 fix(alerts): fresh mailer at queue time (poller!) + branded HTML alert e-mail
ROOT CAUSE of "alert fires but no e-mail", third and final layer: the
metrics poller (clusev:poll-metrics, a long-lived supervisor process) is
what queues alert mail — and Laravel bakes the DEFAULT MAILER NAME into a
mailable at Mail::to()->queue() time. A poller that booted before SMTP
was configured bakes `log` into every alert e-mail forever; the worker
then faithfully "sends" them into laravel.log no matter how correct its
own config is. This is why manual test sends (fresh processes → smtp)
arrived while real alert mails (poller → log) never did.

Fix: extract the SMTP-settings application into App\Support\MailSettings
(::apply(), now with Mail::purge on change so long-lived processes
rebuild the transport) and call it from all three process shapes:
  - web requests   → AppServiceProvider::boot()
  - queue worker   → Queue::before
  - poller/loops   → AlertNotifier::notify() right before queueing,
                     so the CURRENT mailer name is baked in.

Also: enterprise HTML alert e-mail (frontend-design). Dark Tactical
Terminal card — signal rail + status pill (red ALARM / green BEHOBEN),
rule headline, mono terminal readout (server/metric/value/threshold),
bulletproof dashboard CTA, CID-embedded logo (no external assets; renders
in Gmail/Outlook with images blocked), full de/en, plain-text part kept
as fallback. Offline rules render state text (offline/back online)
instead of a bare 0/1.

Verified: app restarted (poller reloaded), synchronous + queued sends to
the admin both deliver over real SMTP (476ms send, no new log-mailer
entries). 750 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:14:52 +02:00
boban 5024675c94 fix(alerts): send one e-mail per recipient so a bad address can't drop all
The alert e-mail addressed ALL recipients in a single To:. When the admin
fallback list contains an undeliverable address (e.g. a seeded admin on a
non-routable domain), a receiving provider like Gmail sees the mixed
To: including a nonexistent recipient, scores the message as spam, and
silently drops it — for the valid recipients too (not even the spam
folder). A single-recipient test mail from the same relay arrived fine,
which is what isolated it: the shared To: was the difference, not SMTP.

Send one message per recipient instead (best-effort, per-address error
logging). A bad address now only fails its own send, and no recipient's
address leaks to the others. Diagnosis confirmed the worker sends over
real SMTP (mail.default=smtp, host=mail.thinkidoo.com) — this was purely
the recipient-list shape hurting deliverability.

Tests updated: two configured recipients now queue two messages, each
with a single To.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:56:23 +02:00
boban f9a08a98d2 fix(ui): uniform action-element height + live fleet status / alerts
Two things the badge/live-update fixes missed:

Height — the enable/disable TOGGLES (Alerts, Servers, Commands, Settings)
were custom buttons at min-h-9 (36px) while x-btn / x-modal-trigger /
status-pill are h-8 (32px), so a status toggle never lined up with the
Delete/Create buttons next to it. Normalise those toggles to h-8.
Verified: Enabled/Delete/Create all render at 32px now.

Live — the topbar "X/Y online" pill and the sidebar server-switcher were
static Blade, so a server going up/down (shown live on the dashboard via
Reverb) didn't reach them until a navigate/refresh. Make the topbar pill
a small polled Livewire island (Shell\FleetStatus) and add wire:poll to
the server-switcher + the Alerts page, so fleet status and firing/
resolved incidents update live without a refresh.

(The alert e-mail is confirmed sending over SMTP — queued mail no longer
hits the log mailer, and a synchronous send to the admin returned SEND_OK
from mail.thinkidoo.com:587. Non-delivery is downstream: spam filtering
or missing SPF/DKIM for hello@clusev.com via that relay — a DNS/mail-
server config, not a panel code issue.)

750 tests; R12-verified (uniform heights, topbar is a live island, zero
console errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:42:09 +02:00
boban 6dbe2303a4 fix(ui): sidebar alert/threat badges update at once, not up to 60s late
The Alarme + Threats nav badges were cached for 60s and the cache was only
ever left to expire, so when an alert fired (or a fake-login was recorded)
the sidebar count lagged the page content by up to a minute — the content
showed the new state, the badge did not until a refresh.

Centralise each count on its model (AlertIncident::firingCount /
AuditEvent::threatLoginCount24h, still cached) and BUST that cache the
instant the state changes: AlertEvaluator on every fire/resolve,
HoneypotController on each recorded fake login. The badge now matches the
content on the very next render (navigate or refresh) while keeping the
per-render query off the hot path.

(The alert e-mail itself was already fixed by the earlier Queue::before
change; it just needed the long-lived queue worker restarted to pick up
the new code — verified the worker now sends via SMTP, not the log mailer.)

750 tests (new cache-bust coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:22:51 +02:00
boban b0c46f2b18 perf/cleanup: one SSH round-trip for Docker, drop dead i18n, dedupe row
Optimizations from a multi-agent analysis pass (low-risk, verified):

- Docker pages did TWO SSH connect/login/exec cycles per load —
  available() (`command -v docker`) then containers() (`docker ps`).
  Fold availability into the single `docker ps` call: a missing binary
  now throws App\Exceptions\DockerNotInstalled (detected from the ps
  error), which the pages catch for the "not installed" state. Halves
  the SSH round-trips on the host Docker page AND the per-server Docker
  tab, and drops each container action from 3 connects to 2 (action +
  one reload probe instead of two). available() stays for its own test/
  reuse but is no longer on the hot path.

- Remove 13 dead translation keys left by the Docker/Terminal split
  (terminal: heading/subtitle/targets_*/servers_heading/search_*/no_*/
  pick_target; docker: target_label/server_target) — confirmed 0
  references, de+en kept in parity.

- Extract the duplicated container row + state→pill mapping into
  x-docker-container-row, shared by the host Docker page and the
  per-server Docker tab (one place to restyle/fix the row).

749 tests (new DockerNotInstalled unit + component coverage);
R12-verified (host Docker + server Docker tab render, zero console
errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:06:27 +02:00
boban 4d349eebb9 fix: alert mail on queue, SMTP reset, cert/uptime auto-probe, pill heights, XSS
Batch of user-reported fixes + two findings from the nav-split audit.

Bugs:
- Alerts never e-mailed. AppServiceProvider::applyMailSettings() ran only
  at boot; the long-lived QUEUE worker booted once, so SMTP configured
  after worker start (the normal case) meant queued alert/reset mail used
  the stale/log mailer. Re-apply the DB SMTP config before every job
  (Queue::before) so queued mail always sends with the current settings.
- SMTP could not be fully reset. Add Email::clearConfig() (+ a Reset
  button + Setting::forget) that removes every mail_* setting, reverting
  the panel to the safe default (log) mailer.
- New cert endpoint / uptime check showed no validity/status until a
  manual refresh. Certs\Index::addEndpoint + Health\Index::addCheck now
  probe the just-added row immediately.

Audit findings (096ba3c..HEAD):
- HIGH stored/DOM XSS: the per-server Terminal tab rendered the server
  name inside an Alpine x-text JS sink (`x-text="title || '{{ name }}'"`);
  Blade escaping does not protect that context. Move the fallback to a
  data-attribute read as a plain string (server-terminal + the host
  terminal for consistency).
- LOW: /terminal is now admin-only, but the command-palette `g t` leader
  key still sent every role there → 403. Drop it from the static map;
  render it as a role-filtered nav entry (admins only).

UI:
- status-pill is h-8 now (the small-button height) so a status pill and
  the action buttons in the same row line up (Services/Certs/Docker/…).

748 tests (new clearConfig test); R12-verified (uniform pill/button
heights). Marketing docs at ~/clusev-site reworded to remove the
contradictory domain/CLUSEV_DOMAIN install note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:46:46 +02:00
boban b4c309bd54 feat(nav): 3-group sidebar; split Docker + Terminal into host page + per-server tabs
Restructure the navigation so host-level tools are grouped separately and
the host vs per-server split is explicit.

Sidebar — three groups instead of two:
  • Flotte: Dashboard, Servers, Services, Commands, Files, Audit
  • Host:   Docker, Terminal, WireGuard, System, Security posture, Patch,
            Certificates, Uptime  (things that act on the Clusev host)
  • Konto:  Settings, Alerts, Threats, Versions, Release, Help

Docker & Terminal are now split by target:
  • Sidebar Docker = the CLUSEV HOST's containers; sidebar Terminal = the
    host shell. Both admin-only (manage-fleet, route + mount guarded) — the
    control-plane machine, same bar the host shell already used.
  • Per-server Docker + Terminal move to the server-details page as tabs
    (Übersicht | Docker | Terminal, ?tab= deep-linkable). New lean
    components Servers\ServerDocker + Servers\ServerTerminal; the metrics
    poll is suppressed off the overview tab. Viewing a server's containers
    is open to any role; actions/logs/shell require operate.

Also: Docker\Index + Terminal\Index reduced to host-only (no target
toggle / server rail); ServerDocker checks credential()->exists() (the
withExists attribute does not survive Livewire hydration); container rows
drop the noisy port list. Docs at ~/clusev-site updated to match; a
.gitignore guard keeps the separate marketing site out of this repo.

Tests: Docker/Terminal component + RBAC gate tests reworked for the split;
new ServerDockerTest + ServerTerminalTest. 747 pass. R12-verified in a
browser (3-group sidebar, host Docker/Terminal, server tabs switch, real
per-server containers, zero console errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:21:13 +02:00
boban 096ba3ca98 harden(compose): scope host-gateway route to the web app only (audit finding)
A multi-agent security audit of this branch flagged that the new
`extra_hosts: host.docker.internal:host-gateway` sat on the shared
x-app/x-image anchor, so reverb/queue/scheduler inherited it too. Only
the web `app` container serves the (admin-gated) "Clusev host" Docker
view; the workers run background code with no role gate. Combined with
the image's ability to decrypt the root HostCredential and open root SSH
via HostCredential::toServer()+FleetService, every worker held a network
path to a root shell on the host — defeating the manage-fleet gate that
only exists in the HTTP layer.

Not reachable as shipped (no job/scheduled code touches the host
credential), but a least-privilege regression. Move extra_hosts off the
anchor onto ONLY the `app` service (dev + prod). Verified: app resolves
host.docker.internal, queue/scheduler do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 01:15:35 +02:00
boban 4c67b278bd fix(docker): admin-gate the host target + honour a "0" key passphrase (Codex review)
Two findings from the Codex review of the host-Docker changes:

1. Under-gated host target (security). Listing/actioning/log-reading the
   CLUSEV HOST's containers only required `operate`, but the host is the
   control-plane machine — a `Stop` on clusev-mariadb takes Clusev down —
   and the host TERMINAL is already `manage-fleet`-only. Align it:
   - effectiveTarget() resolves to the host only for manage-fleet users;
     everyone else is pinned to a fleet server (no host default, no toggle).
   - action()/viewLogs() and the ContainerLogs host sentinel (serverId 0)
     now abort_unless manage-fleet.
   A viewer/operator can no longer reach the host, even by forcing
   target=host.

2. HostCredential::toServer() dropped a key passphrase equal to the string
   "0" via a falsy `if ($this->passphrase)` check, which would break key
   auth. Use an explicit null/'' check.

Added tests: non-admin never targets the host, a forced target=host still
hits a fleet server, operator cannot open host logs. 751 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:43:18 +02:00
boban fd6eba5c29 fix(mariadb): stop logging protocol-normal "Aborted connection" warnings
MariaDB logged an [Warning] Aborted connection ... (Got an error/timeout
reading communication packets) for every DB connection PHP closed without
the COM_QUIT handshake — i.e. whenever a long-lived worker (queue/schedule/
reverb) was recycled, a php-fpm worker retired, or a container restarted.
These are protocol-normal, not a fault: Aborted_clients was 46 over ~24
days uptime (~2/day) with a non-persistent PDO pool and no connection leak.

They surface only because the mariadb:11 image ships log_warnings=2. Add
docker/mariadb/tuning.cnf (mounted in dev + prod) setting log_warnings=1 —
genuine errors still log, the expected-disconnect noise stops — plus a
higher net_read_timeout (120s) so a momentarily-stalled worker read isn't
aborted as aggressively. Verified: forcing app+queue restarts now produces
zero new "Aborted connection" lines. The DB is internal-network only, so
dropping the level-2 access-denied notes carries no real exposure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:34:04 +02:00
boban 3fae5293e2 refactor(docker): drop the noisy port list from the container row
The raw docker port column (e.g. "0.0.0.0:80->80/tcp, [::]:80->80/tcp,
9000/tcp, ...") made rows long and hard to scan for little day-to-day
value. Show just the container name + image; the image already gives the
useful "what is this" at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:58:02 +02:00
boban c253e79c0c feat(docker): show the Clusev host's own containers (host target)
The Docker page only ever targeted fleet servers, so an operator whose
containers run ON the Clusev host itself (the clusev stack + anything
else on that machine) saw nothing — the page has "nothing to do with
the fleet" from their point of view. It now has a target switch:

  • Clusev host (default when a host SSH login exists) — lists the
    containers on the machine Clusev runs on, reached over the local
    Docker gateway with the existing encrypted HostCredential, the same
    login the host terminal uses.
  • the active fleet server — the previous behaviour, still available.

Mechanics:
- HostCredential::toServer() builds a TRANSIENT Server (never persisted)
  carrying the host login; DockerService/FleetService run unchanged
  against it. The logs modal resolves serverId 0 to this host server.
- VerifiesHostKey only pins (writes ssh_host_key) for persisted fleet
  servers now; a transient host connection no longer spawns a junk
  fleet row on every request. The host is the local gateway — no
  network path to MITM — so skipping the pin there is safe.
- The `app` service (dev + prod compose) gains the
  host.docker.internal:host-gateway mapping the terminal sidecar already
  had, so the PHP layer can reach the host's sshd. Without it the app
  container cannot resolve the host at all — the real reason host Docker
  never worked.
- Absent runtime still renders the clean "not installed" state; a
  host target with no login yet shows a setup hint.

Verified end-to-end in a browser: the page defaults to the host and
lists the full running stack (clusev-app/mariadb/redis/… + others) with
Logs/Restart/Stop actions. 748 tests green (5 new host-target tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:43:36 +02:00
boban 7e6c01d5fa fix(docker): honest "not installed" state instead of a raw shell error
When a fleet host has no container runtime at all (e.g. a native
postfix/dovecot mail server), `docker ps` returns "sh: docker: not
found". The page surfaced that raw shell text inside a red error box,
which reads as a broken feature — the user reasonably expected the tab
to show containers.

Probe `available()` first: if the docker binary is genuinely absent,
render a clean, neutral "Docker nicht installiert — es gibt keine
Container zum Anzeigen" panel (box icon, no alarm styling) rather than
the shell error. A real fault (daemon down / permission) still shows
the actual error as before. Verified end-to-end against a real SSH host
with no runtime (the honest state) and against a real docker host (full
container list + Logs/Restart/Stop actions render).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:11:53 +02:00
boban 4ab255832d fix(docker): find the docker binary regardless of the login shell PATH
A non-interactive SSH exec (and sudo's secure_path) frequently has a
minimal PATH (~/usr/bin:/bin). Docker installed in /usr/local/bin
(get.docker.com), /snap/bin (snap) or /usr/sbin is then unreachable, so
every call died with "sh: docker: not found" and the page rendered as
"Docker nicht verfügbar" even though Docker was installed and running.

Prepend a comprehensive PATH to every docker invocation (available,
containers, containerAction, logs, composeStacks) so the binary is
located independent of the remote login shell. Reproduced the exact
failure and the fix through the real FleetService base64+sh transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:33:45 +02:00
boban 7535ee2d63 fix(ui,docker): normalise oversized buttons + surface the real Docker error
Feedback on the new feature pages: buttons were oversized and Docker showed an empty list where
containers exist.

- Buttons: the new pages (groups, alerts×2, commands×2, certs, health) plus settings/login-protection
  used size="lg" (h-11) on inline form-submit buttons, dwarfing the h-9 inputs and the surrounding
  pills. Drop to the default `sm` (h-8) — matching every other form in the app (e.g. the e-mail save
  button). The auth full-width CTAs keep size="lg" (their intended use); x-ring size="lg" is unrelated.
- Docker "empty" bug: DockerService::containers used `docker ps --format '{{json .}}'` (needs a modern
  Docker AND depends on JSON key casing) and returned [] on ANY failure — so a daemon/permission/
  rootless-socket error read as "no containers". Switched to a tab-separated `--format` (portable to
  much older Docker, state derived from the Status text) and it now THROWS the real error on failure;
  the page shows that reason instead of a misleading empty state (so the actual cause is visible).
- Patch rows: the pending/security/up-to-date pills now match the action button height (h-8) so the
  row reads cleanly.

DockerServiceTest updated (tab-parse + state-from-status + throws-real-error). 742 tests green, Pint,
vite build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:22:49 +02:00
boban 65b92bc65c feat(uptime): HTTP/TCP health checks + status board (feature 8/8)
Uptime monitoring for the services the fleet hosts: HTTP and TCP probes with a live up/down status
board. Completes the monitoring trio (alerts, certs, uptime).

- HealthService::probe(check): native — Laravel HTTP client (withoutRedirecting; reports only the
  status code + latency, never the response body → no internal-content exfil) for http, a PHP TCP
  stream for tcp. No SSH, no shell, so the target is never interpolated into a command. 2xx/3xx = up;
  a 4xx/5xx means "answered but unhealthy"; a connect error = down.
- Health\Index page (route /health, `operate`-gated: route + mount + per-method). Add/remove checks
  (http target validated as a real http(s) URL; tcp target as a hostname/IP + a required port). Lazy
  per-check probe, each guarded. Up/total summary. Delete via signed ConfirmToken + R5 modal.
  Add/delete audited.
- lang/{de,en}/health.php + audit.php health.check_* + shell.nav_health (de/en parity). No emoji.

11 new tests: service (http 2xx up / 5xx down / connection-error down, tcp refused-port down),
component (route gating, add http + audit, reject non-url http target, tcp requires host+port,
reject missing port, scan probes each check, delete via confirmed token). 742 tests green, Pint,
lang parity, self-reviewed. A PUBLIC (unauthenticated) status page is a documented future.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:04:14 +02:00
boban 3cab72bf46 feat(security): TLS certificate expiry monitoring (feature 7/8)
Monitor the certificate expiry of configured TLS endpoints (the panel's own domain, fleet
services, anything reachable) and flag the ones due soon — before they lapse.

- CertService::check(host, port) reads the peer certificate NATIVELY via a PHP SSL stream +
  openssl_x509_parse — no SSH, no shell, so `host` is never interpolated into a command. Trust is
  intentionally NOT verified (verify_peer off): the goal is to report expiry even for a self-signed
  or already-expired cert. certInfo() (subject/issuer/expiry/daysLeft) is split out for unit testing
  against a generated cert; status() maps daysLeft → ok / warning (<30) / critical (<7) / expired.
- Certs\Index page (route /certs, `operate`-gated: route + mount + per-method). Add/remove endpoints
  (host validated as a hostname/IP — rejects scheme/path/shell chars; port 1-65535). Lazy per-endpoint
  scan, each guarded. Delete via signed ConfirmToken + R5 modal. Add/delete audited. Internal
  endpoints are allowed on purpose (monitoring internal service certs is legitimate; the check only
  reads a cert, it POSTs nothing).
- lang/{de,en}/certs.php + audit.php cert.endpoint_* + shell.nav_certs (de/en parity). No emoji.

10 new tests: service (certInfo extracts subject/expiry from a generated cert, status thresholds,
check errors on an unreachable endpoint), component (route gating, add + audit, reject scheme/
metachar host + out-of-range port, scan checks each endpoint, delete via confirmed token). 730 tests
green, Pint, lang parity, self-reviewed. LE issuance is a documented future (needs ACME on the host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:57:53 +02:00
boban 79862ab014 feat(patch): fleet patch view + security-update awareness (feature 6/8)
Fleet-wide package-update visibility: pending + SECURITY update counts per server, one-click patch.
Extends MaintenanceService (single-server pending/apply already existed) with the security subset
and a fleet aggregation.

- MaintenanceService::updateCounts(Server) → { pending, security } in ONE round-trip via a new
  PackageManager::countsScript() (apt/dnf/zypper). Security is best-effort per family (apt path solid;
  dnf/zypper degrade to 0 when the metadata isn't there) and clamped ≤ pending. Either value is null
  when the query can't run (shows "unknown", never a misleading 0).
- Patch\Index page (route /patch, `operate`-gated: route + mount + per-method). Lazy per-server scan,
  each guarded (an unreachable host → error row). Fleet KPIs (total pending / total security). Applying
  upgrades is a stateful action → signed ConfirmToken + R5 modal (modal audits patch.apply from the
  sealed token; handler does not re-audit) resolving a client uuid → the sealed server id; a forged
  token is a no-op. Reuses the existing applyUpgrades; the result output is mb_scrub'd before it lands
  in a Livewire prop.
- countsScript strings are STATIC per manager (no operator input) — no injection surface.
- lang/{de,en}/patch.php + audit.php patch.apply + shell.nav_patch (de/en parity). No emoji.

11 new tests: MaintenanceService (parse pending+security, clamp security≤pending, missing-sentinel /
unsupported-manager → unknown), component (route gating, scan shows counts + survives an unreachable
server, patch opens confirm, applyPatch runs upgrades over the SEALED server, forged token no-op).
720 tests green, Pint, lang parity, self-reviewed (RBAC, ConfirmToken seal, static scripts, scrub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:51:17 +02:00
boban 31852fc04c feat(security): fleet security-posture score (feature 5/8)
A per-server hardening SCORE + a fleet scoreboard, so an operator sees at a glance which servers
are hardened. Reuses the (already-reviewed) HardeningService::state gates rather than re-probing;
remediation for a failing check is the existing hardening toggle on the server-details page.

- PostureService::score(Server): aggregates the hardening state into { score 0-100, rating
  strong/fair/weak, passed, applicable, checks[] }. Only real security gates count — the neutral
  auto-updates preference and OS-unsupported items are excluded, so no server is penalised for a
  check it can't run.
- Posture\Index page (route /posture, `operate`-gated: reading a server's posture exposes its gaps).
  Lazy wire:init scan of every active-credential server, each guarded — one unreachable host shows an
  error row, never blocks the scan. Fleet-average KPI + per-server score/rating + the open gates.
- lang/{de,en}/posture.php + shell.nav_posture (de/en parity). Status via the triad, no emoji.

9 new tests: service (score = passing/applicable ratio, all-secure=strong-100, neutral+unsupported
excluded, all-fail=weak, no-applicable=full), component (route gating viewer 403 / operator ok, scan
scores each server, an unscannable server shows an error row not a crash). 709 tests green, Pint,
lang parity. (No destructive actions / new SSH — a read-only aggregation of Codex-validated hardening
state; self-reviewed for RBAC, scan-loop containment, and blade escaping.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:43:56 +02:00
boban 5e4f29216d feat(commands): ad-hoc fleet commands + runbooks (feature 4/8)
The multi-server amplifier: run a command (ad-hoc or a saved runbook) across the whole fleet,
a group, or a selection, with a per-server result. Because it is arbitrary remote execution,
authorization + audit are the whole feature.

- The command is intentionally arbitrary; it runs via FleetService::runPlain (the credential's
  login user, base64 transport, NO extra sudo) — the same privilege the web terminal already
  grants, batched. No injection to prevent; the concern is who may run + that every run is logged.
- CommandRunner.run: per-server runPlain, one server failing never aborts the batch, output
  byte-capped (256 KiB) + mb_scrub'd before it reaches a Livewire property.
- Commands\Index page, fully `operate`-gated: route can:operate + mount() + a per-method gate() on
  run/runRunbook/execute/createRunbook/confirmDeleteRunbook/deleteRunbook. Every run goes through a
  signed single-use ConfirmToken + R5 confirm modal ("run on N servers?"); the modal audits
  command.run from the sealed token so execute() does not re-audit. Targets resolve client UUIDs →
  ids server-side (withActiveCredential + inGroup + whereIn), and {command, serverIds} is SEALED in
  the token — a client can't swap the command or targets after confirm. A forged token is a no-op.
- Runbooks: saved name+command, operate-gated CRUD, delete via ConfirmToken.
- lang/{de,en}/commands.php + audit.php command.run / runbook.* + shell.nav_commands (de/en). No emoji.

15 new tests: CommandRunner (runs each server, one-failing-doesn't-abort, nonzero=not-ok, byte-cap
+scrub), component (route gating, ad-hoc run opens confirm, empty-command / no-target errors, execute
runs the SEALED targets, forged token runs nothing, group scope targets only members, runbook create
+audit + run + delete-via-token). 700 tests green, Pint, lang parity, Codex-reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:38:09 +02:00
boban 35b7c15598 feat(docker): container management over SSH (feature 3/8)
Manage the containers running ON a fleet server, agentlessly — the integrated Portainer.
List containers + state, view logs, start/stop/restart, all via `docker` over SSH.

- DockerService (over FleetService::runPrivileged → base64 transport, shell-injection-safe):
  containers() parses `docker ps -a --format '{{json .}}'`; containerAction() with an op allow-list
  (start|stop|restart|pause|unpause); logs() with a clamped tail; composeStacks() read-only. Every
  container ref is validated `^[a-zA-Z0-9][\w.-]*$` before interpolation — blocks shell metacharacters
  AND a leading-dash argument injection (mirrors serviceAction). A missing/errored docker → [] (never
  throws to the UI).
- Docker\Index page (route /docker, sidebar "Docker" with a new box icon): lazy container list for the
  active server, open to any role. Container actions are `operate`-gated (abort_unless per method) and
  audited (docker.action). Reading LOGS is also operate-gated (container output can leak secrets —
  consistent with gating file CONTENT reads); the list/state stays open. ContainerLogs modal re-gates
  the read itself.
- lang/{de,en}/docker.php + audit.php docker.action + shell.nav_docker (de/en parity). No emoji.

Codex hardening applied: logs output is byte-capped (head -c 262144) AND mb_scrub'd before it reaches
a Livewire property (invalid UTF-8 / a huge line can't break the JSON snapshot); the ref regex uses
\A…\z anchors so a trailing newline can't slip past.

16 new tests: service (json parse, empty-on-unavailable, valid-op command, reject unknown-op / shell-
metachars / leading-dash / trailing-newline ref, logs clamp+byte-cap, available probe), component
(viewer browses the list; viewer 403 on action AND on logs open + modal read; operator/admin act +
audited; logs modal tail). 685 tests green, Pint, lang parity, Codex-reviewed (fixes applied).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:23:56 +02:00
boban 0472b3531a feat(alerts): threshold alerting + notifications (feature 2/8)
Turns the existing metrics into an ops tool: rules that fire when a server crosses a
threshold and resolve when it recovers, with e-mail + webhook notifications. The event
sink later features (cert-expiry, uptime-down, patch-available, posture-drop) plug into.

- alert_rules (metric cpu|mem|disk|load|offline, comparator gt|lt, threshold, scope
  all|group|server) + alert_incidents (firing|resolved, FK cascade).
- AlertEvaluator — per-server state machine: opens ONE firing incident on a fresh breach
  (notifies), resolves on recovery (notifies), dedups a sustained breach (one breach = one
  alert). Numeric metrics skip a truly-offline server (stale reading); the `offline` rule
  fires only on offline, not a reachable "warning".
- AlertNotifier — best-effort e-mail (queued Mailable to configured recipients, falls back
  to admin e-mails) + generic webhook JSON POST (Slack/Discord/Mattermost/custom; only http(s)
  URLs). Every channel is wrapped so a broken SMTP/unreachable hook is logged, never thrown.
- PollMetrics evaluates after each poll (online, fresh status) and on a failed poll (offline),
  each guarded so an alerting error can never kill the poll loop.
- Alerts\Index page, manage-panel (admin): route can:-mw + mount() + per-method gate() on every
  mutation. Rule delete via signed ConfirmToken + R5 modal (no double audit). Scope group/server
  resolves a client UUID to an id server-side (never trusts a client id). Channels saved to
  Settings. Sidebar "Alarme" nav + firing-incident badge (cached 60s).
- lang/{de,en}/alerts.php + audit.php alert.* + shell.nav_alerts (de/en parity). No emoji.

Codex review raised three MEDIUMs, all fixed here:
- Webhook SSRF: strict http(s) scheme + reject hosts resolving to loopback/private/link-local/
  reserved ranges (blocks the 169.254.169.254 cloud-metadata classic), validated at save AND send,
  redirects disabled — so an admin-configured hook can't be pointed at an internal service.
- Incident dedup is now a DB invariant: a unique `firing_key` ("{rule}:{server}" while firing,
  null on resolve) means two concurrent poll ticks can't both open a duplicate incident (the loser
  hits a unique violation, caught → no-op).
- `load` is float-safe: threshold + incident value are decimal, compared as floats, so load 1.5 vs
  threshold 1 fires (it used to truncate to 1 > 1 = false).

31 new tests: evaluator (fire/resolve/dedup, offline vs warning, scope all/group/server, disabled,
fractional-load, firing_key unique, key-freed-on-resolve), notifier (email/fallback/webhook/
non-http-ignored/failure-swallowed/SSRF-reject/public-accept), component (RBAC route gating, rule
CRUD, uuid→id scope resolve, offline→threshold 0, toggle, delete via token, channels, unsafe-webhook
rejected, incidents render). 669 tests green, Pint, lang parity, Codex-reviewed (fixes applied).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:07:54 +02:00
boban 40d8dfc96d feat(fleet): server groups — organise the fleet + filter (feature 1/8)
Foundation for the feature program (groups are the scoping primitive alerts, ad-hoc
commands, bulk actions and patch/posture will target). Lean by design: named groups,
no hierarchy, no per-group RBAC.

- server_groups (uuid route key, unique name, color = a @theme token key coerced to a real
  token on save) + server_server_group M2M pivot (cascade on delete; servers survive a group
  delete). Server::groups() + a `scopeInGroup`.
- Servers\Groups full-page component, manage-fleet (admin) only — route can:-mw + mount()
  abort_unless + a per-method gate() on every mutation (create/rename/setColor/toggleMember/
  confirmDelete/deleteGroup), since /livewire/update does not re-run route mw. create/rename/
  recolour/membership audit directly; DELETE goes through the signed single-use ConfirmToken +
  the R5 confirm modal (which writes group.delete from the sealed descriptor, so the handler
  does not double-audit). 'groupConfirmed' added to ConfirmToken::ACTIONS.
- /servers/groups is registered BEFORE /servers/{server} so "groups" isn't captured as a uuid.
- Servers\Index: a #[Url] group filter (uuid; shareable) — an unknown uuid falls back to all,
  never errors. Filtering is open to every role; only management is admin-gated. A "Gruppen"
  button (admin) on the fleet header; colour pills use a literal @theme token map (R3).
- lang/{de,en}/groups.php + audit.php group.* labels (de/en parity). No emoji.

19 new tests (model + component: uuid/colour-allow-list/relations/cascade, RBAC route gating,
create/rename/recolour/membership, delete-via-confirmed-token, forged-token no-op, filter).
638 tests green, Pint, lang parity, Codex-reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:36:17 +02:00
boban aa854916ce harden(opsec): disguise the honeypot naming on the Threats + settings pages too
Follow-up to bf4366d (audit log): remove the last user-visible mentions of the deception layer
so nothing in the running panel names it "Honeypot" — a shoulder-surf, a shared screen, or a
breached admin session can no longer tell the decoy paths are a trap. Admin-only surfaces, but
closes the gap for full coverage.

- Threats page: subtitle "Honeypot activity" -> "Attack activity"; feed title "Honeypot events"
  -> "Security events"; the Honeytoken KPI -> "Leaked credentials" (DE mirrors).
- Settings: the "Honeypot" status block -> "Threat detection" / "Angriffserkennung"; subtitle and
  note reworded to drop "deception layer"/"decoy paths"/"Täuschung"/"Köder", and the note no longer
  prints the CLUSEV_HONEYPOT env-var name (it named the mechanism).

DB action keys, the CLUSEV_HONEYPOT env var itself, config, and dev code comments are unchanged
(the source is public AGPL, so comments carry no OPSEC value — only the runtime UI matters). A new
regression test asserts the Threats page renders the disguised labels and never shows
"Honeypot"/"Honeytoken"/"Täuschung"/"Köder". 619 tests, de/en parity, Pint; a scan confirms zero
visible "Honeypot"/"Honeytoken" left in any lang value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:06:46 +02:00
boban bf4366dfc9 harden(opsec): disguise honeypot action labels in the audit log
The audit log (readable by every role) rendered "Honeypot-Treffer" / "Honeypot hit" next to
the decoy paths, which advertises the deception layer to anyone with panel/audit access — an
attacker seeing it would know wp-admin/phpmyadmin/etc. are traps. Rename the VISIBLE labels to
neutral WAF/IDS-style threat logging so the mechanism is no longer self-revealing. The DB action
keys stay security.honeypot_* (Threats intel, history, error styling, and tests key off them);
only the human-facing text changes:

  security.honeypot_hit    -> "Angriffsversuch"              / "Attack attempt"
  security.honeypot_login  -> "Login-Angriffsversuch"        / "Login attack attempt"
  security.honeytoken_used -> "Kompromittierte Zugangsdaten" / "Compromised credentials"

This covers the /audit page, the dashboard audit panel, and the Threats feed rows (all resolve
the same audit.actions label). A regression test asserts the audit log renders the disguised
labels and never leaks "Honeypot"/"Honeytoken" (nor the raw key). 618 tests, de/en parity, Pint.

NOTE: the admin-only Threats page header (subtitle/feed title/Honeytoken KPI) and the settings
honeypot-status section still name it "Honeypot" — those are manage-panel-gated; disguising them
too is a separate, optional follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:59:56 +02:00
boban 930f78efbd polish(ui): in-system design refinements from the design review
Surgical consistency/polish tightening within the existing "Tactical Terminal"
system — no re-theme, no new tokens/fonts/colors, every change uses an existing
@theme utility. From a design review (subagent-cataloged, false-positives rejected).

- nav-item: badge tooltip is now a contextual `badge-title` prop instead of a hardcoded
  "update available" — the Threats count badge showed the wrong tooltip. Sidebar passes a
  real "N login attempts (24h)" title for Threats and "Update available" for Versions.
- empty-state messages (sessions/users/webauthn-keys/login-protection) bumped from the
  faintest ink-4 to ink-3 — when a list is empty that line is the focal point, not meta.
- modal actions: fail2ban-config / hardening-action / system-update submit buttons go from
  the softer `accent` to solid `primary` so the real action reads as primary (cancel stays
  secondary; the done-state close is unchanged).
- servers list: CPU/RAM mini-bar labels ink-4 -> ink-3 to match their values.
- edit-credential: top-level error now uses the standard bordered offline error box
  (border-offline/25 bg-offline/10 + alert icon), matching firewall-rule.
- file-editor: title + path get a native `title=` tooltip so a truncated long path is legible.
- add-ssh-key label rhythm (mb-1.5 -> mb-1); login-protection textarea vertical padding
  (py-2 -> py-2.5, the textarea norm); webauthn-keys add button w-full sm:w-auto so it isn't
  a mobile orphan; app.css font comment de-staled (fonts are self-hosted).

Deliberately NOT changed (would harm the design, not help): mail from_name kept sans vs
from_address mono (name = prose, address = technical token — the convention); the Memory
metric tone kept dynamic/threshold-based (health signal) rather than forced cyan.

Verified: vite build compiles all utilities, 617 tests green, de/en lang parity, DOM render
check of the badge + button variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:06:32 +02:00
boban 0943d56201 harden(update): verify the self-update commit signature before the root re-exec/build
The re-audit's top finding (HIGH): update.sh runs as root, pulled + re-exec'd its own new copy +
ran install.sh's `docker compose build` on the pulled tree with NO signature check, so a
compromised/redirected origin or a transport MITM could feed attacker commits into a root RCE. The
branch-pin (bc2f2b5) stops a local `git checkout` redirect but not a remote origin compromise —
only a signature does.

- update.sh: after the pull and BEFORE the self-update re-exec / install.sh build, verify the pulled
  HEAD with `git -c gpg.ssh.allowedSignersFile=<anchor> verify-commit HEAD`. Opt-in and safe by
  default — skipped with a warning when no anchor is configured (existing installs keep updating),
  but FAIL CLOSED once configured. The anchor is resolved from .env CLUSEV_ALLOWED_SIGNERS or the
  gitignored .clusev-allowed-signers; a SET-but-missing anchor dies (no silent downgrade), and a
  git-TRACKED anchor is refused (a pulled tree could otherwise swap in the attacker's own key).
- clusev-release.sh: opt-in SSH signing of the release commit + tag when CLUSEV_SIGNING_KEY is set,
  so the verify side has provenance to check. Unsigned (identical behaviour) when unset.
- .gitignore /.clusev-allowed-signers (the trust anchor must never be pull-overwritable);
  .env.example documents CLUSEV_ALLOWED_SIGNERS.

verify-commit + allowed-signers proven end-to-end in a scratch repo (signed→pass, unsigned→fail,
wrong-key→fail, tracked-anchor→refuse, set-but-missing→fail-closed). shellcheck clean. Activation
(provision the maintainer keypair + sign releases) is a documented maintainer step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
boban 46924135e6 perf(fleet,wg) + i18n(dashboard,update) + a11y(btn): re-audit design/perf cleanups
From the re-audit's design/perf pass (Codex-reviewed):

- WithFleetContext::fleet() memoises per request (protected, not serialised by Livewire) —
  activeServer() calls it several times per render (Files ~6x, Services ~4x), so it was re-running
  the fleet query each time.
- Wireguard: cache WgTraffic->series() (60s) keyed on the window + a data fingerprint
  (MAX(id) + COUNT) so a new/pruned sample busts it, but the 5s wire:poll no longer re-buckets the
  whole sample history every tick (samples only land once a minute).
- dashboard: the systemd table headers (Unit/Status/Boot) were hard-coded English while a sibling
  column used __(); move all four through lang keys (de+en parity).
- update-progress: localise the phase-list aria-label.
- btn: the compact `sm` size grows to the 44px R7 touch target on a coarse pointer only, so dense
  row/toolbar buttons are tappable on mobile while desktop density is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
boban 6927987a0d harden(fleet,infra): enforce credential revocation on the poller + prod container hardening
From the whole-codebase re-audit (deferred MEDIUM findings, Codex-reviewed):

- poller: CredentialVault enforces a credential's disabled_at lock only at connect(), but
  PollMetrics reuses one long-lived SSH connection per server, so a credential disabled
  mid-run kept streaming metrics over the already-open session. Add Server::withActiveCredential()
  (whereHas credential whereNull disabled_at); the poller selects via it and prunes any cached
  client whose server left the active set, so a revocation drops the server on the next tick and
  closes its session promptly.
- prod compose: add security_opt no-new-privileges:true to every service + a generous pids_limit
  (fork-bomb backstop). cap_drop/read_only are deliberately left out — they need a per-service
  prod smoke test (nginx :80 bind, entrypoint chown) before enabling; documented inline.
- Caddyfile + compose: strengthen the TRUSTED_PROXY_CIDR guidance — an over-broad value in
  external-TLS mode lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + ban.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
boban bc2f2b527f fix(security): close file-read RBAC gap + fail2ban arg-injection + update/HMAC hardening
Confirmed-security quick-wins from a full re-audit (6-agent subsystem fan-out,
Codex-reviewed). Each item ships with a regression test proven to fail pre-fix.

- files: gate file CONTENT reads (download / edit / FileEditor::load) behind the
  `operate` ability so a read-only viewer keeps LISTING/browse access but can no
  longer pull file bytes over the server credential (root -> /etc/shadow, keys,
  .env). Hide the download/edit/delete controls from viewers in the blade, and
  basename() Files::open() for parity with the other path builders.
- fail2ban: reject a leading dash/dot in validJail(). A jail name like "-h" or
  "--help" was parsed by fail2ban-client as an OPTION (argument-injection) rather
  than a positional jail; internal dashes ("nginx-http-auth") still pass.
- bruteforce: release the atomically-claimed ban-audit dedup slot when the
  AuditEvent write throws, so a transient DB failure no longer suppresses the
  auth.ip_banned audit for the whole 60s window (the ban itself is unaffected).
- update path: verify_update_request now FAILS CLOSED when UPDATE_HMAC_KEY is
  unset (was fail-open -> any ./run marker could drive a root update). Pin the
  self-update `git pull` to the recorded CLUSEV_BUILD_BRANCH and refuse a diverged
  checkout, so a `git checkout` in the tree cannot redirect the next root update.
- wireguard: normalise the DNS input to a single comma list and QUOTE the WG_DNS
  assignment in wg.env; an unquoted space previously split the assignment and
  broke the next `. "$WG_ENV"`.

13 new regression tests. 614 tests, Pint, shellcheck, Codex review all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:05:16 +02:00
boban c914790a46 harden honeypot/ban path: close evasions, unmask & audit-DoS vectors
Security re-audit + adversarial red-team of the honeypot deception and
brute-force ban layer. Fixes (all with regression tests):

HoneypotController::trap
- BAN EVASION: keyed ban off getRealMethod() not isMethod('post') — the
  latter honors _method / X-HTTP-Method-Override, so a POST spoofed as PUT
  dodged the ban entirely. Now any non-safe verb to a decoy bans.
- REFLECTED-BAN vs EVASION: removed the Origin-based skip; the Origin header
  is attacker-controlled, so it was a trivial universal ban-evasion (send a
  foreign Origin) worse than the narrow griefing it prevented.
- UNMASK/500: mb_scrub all attacker-controlled meta (ua/query/path/creds) so
  invalid UTF-8 can't throw JsonEncodingException on the array-cast column.
- Ban now runs FIRST and independently of the audit (separate guarded
  try/catch) so a failing audit can never skip the ban; deceive() always 200.
- AUDIT DoS: per-IP hourly cap via atomic Cache::add(0)+increment.

DetectHoneytoken::trip
- Same guarded ban-first / audit-second structure + per-IP audit cap
  (bounds an exempt or rotating source) + mb_scrub on the path.

BruteforceGuard::banNow
- Dedup the auth.ip_banned audit per IP+reason on a 60s window (was
  full bantime): collapses the scanner flood without masking a legitimate
  re-ban after an operator unban, and still audits distinct reasons.

Supply-chain / release / dashboard
- Pin prod base images (caddy/mariadb/redis) by @sha256 digest.
- Atomic branch+tag push in clusev-release.sh (no orphan untagged commit).
- Route github.ref_name through a validated $TAG env in ci-staging.yml.
- Strip inline credentials from the origin URL in set-repository-url.sh
  (greedy match handles an @ inside the password).
- WireGuard peer name via Js::from in wire:click; settings badge shows the
  real role; Threats pill/top_ip account for honeypot_login attempts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:05:35 +02:00
boban f804e25a66 refactor(release): single stable channel — cut internal release candidates (-rc), remove all beta wording
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:13:09 +02:00
boban e6f6e7f8a4 fix(dev): read VITE_HMR_HOST from the .env FILE (loadEnv), restoring dev HMR assets
Regression from the leak-scrub: vite.config read $process.env.VITE_HMR_HOST, but the dev container
bind-mounts .env without exporting it into the process environment — so it was ALWAYS empty and the
hmr host fell back to 'localhost'. On a remote dev box the browser then loaded the Vite client + app.css
+ app.js from localhost:5173 (ERR_CONNECTION_REFUSED) → the whole panel rendered unstyled. Switch to
Vite's loadEnv (reads the .env file, same source the other VITE_* vars use) and fall back to undefined
(Vite infers the host from the request) instead of a hard 'localhost'. The real address stays in the
untracked dev .env (documented in .env.example, no IP committed). Verified: dev assets load 200 + the
login renders styled, and `npm run build` (prod) still succeeds.
2026-07-05 11:06:53 +02:00
boban c63af35194 feat(honeypot): surface login attempts in the dashboard (threats KPI, tried-credentials, sidebar badge) 2026-07-05 10:49:38 +02:00
boban 473fa8c0d0 feat(honeypot): trap fake-login submits + capture the attempted credentials
Previously a POST to a fake login hit a Laravel 419 (CSRF) or 405, unmasking the framework, and the
phpMyAdmin/generic forms even posted to real paths (/index.php, /login). Now: the decoy paths are
CSRF-exempt (bootstrap/app.php) and drop BlockBannedIp (an already-banned prober stays inside the
deception, always fed a fake — never a revealing 403 — while the ban still blocks them from the REAL
login). Every fake form posts back to its own decoy, so a submit re-serves the fake page (looks like a
failed login) and trap() records the guessed username + password as threat intel (meta.tried_user /
tried_pass). DetectHoneytoken stays on the group, so a submitted canary is still caught.
2026-07-05 10:34:05 +02:00
boban 6c70d885f7 feat(honeypot): realistic styled decoy pages (WordPress, phpMyAdmin, generic login)
The deceptive bodies were bare unstyled HTML — obvious to any human/scanner. Give each a
self-contained, offline-safe inline-CSS skin that mirrors the real thing (WordPress login grey
canvas + logo + blue button; phpMyAdmin blue theme + logo + fieldset; a clean generic admin card)
so the decoy is convincing enough to hold a probe. No external assets (CSP/airgap-safe).
2026-07-05 10:19:18 +02:00
boban 1b3e058de6 fix(honeypot): route non-existent .php probes through nginx to the trap
nginx's `location ~ \.php$` fastcgi-passed EVERY .php path to php-fpm, which 404'd the classic
attacker probes (/wp-login.php, /xmlrpc.php, /admin.php, /vendor/…/eval-stdin.php) with 'File not
found' BEFORE they could reach the Laravel honeypot route — so those decoys never trapped behind
nginx (the feature tests hit the Laravel route directly, bypassing nginx, and missed this). Add
`try_files $uri /index.php?$query_string` to the php location so a non-existent .php falls through
to the front controller (only index.php actually exists in public/, so nothing else executes).
Verified through nginx: all .php + non-.php + dotfile decoys now serve the deceptive 200.
2026-07-05 09:32:23 +02:00
boban 72de7b9a22 fix(rbac,honeypot): gate audit-retention + email sendTest; honeytoken scans JSON body
Re-review (completeness sweep + Codex) found: Audit\Index::saveRetention wrote the global
audit_retention_days policy ungated (a non-admin could shrink retention and prune audit evidence)
-> manage-panel guard + @can-hidden control; Settings\Email::sendTest lacked the role guard that
mount/save already had -> manage-panel; DetectHoneytoken read only form-encoded bodies via post(),
missing a JSON {"api_key":"<canary>"} replay -> now scans json() too (still body-only, no query).
2026-07-05 02:28:13 +02:00
boban e602320c9d fix(rbac,honeypot): gate settings/release surfaces + per-install canaries, no reflected/auth-user honeypot bans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 02:16:37 +02:00
boban e58d1a4b07 feat(rbac): hide the system-update trigger behind manage-fleet too 2026-07-05 01:51:12 +02:00
boban 8b34ed8ef5 feat(rbac): hide gated controls in the UI with @can (operator/viewer see no dead buttons) 2026-07-05 01:49:38 +02:00
boban 4f9699d470 feat(honeypot): threats dashboard — probe feed, KPIs, banned-IP management
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:38:22 +02:00
boban c8e1e07a43 feat(honeypot): decoy routes + instant-ban + deceptive responses + honeytokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:25:07 +02:00
boban 61b8419106 feat(rbac): user-management roles UI — role badge, selector, role-change with last-admin guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:15:32 +02:00
boban b529201186 feat(rbac): gate service + file mutations behind operate (viewer read-only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:06:06 +02:00
boban 25ded63d87 feat(rbac): gate fleet, host terminal + domain/TLS actions (manage-fleet/operate/manage-panel)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:01:24 +02:00
boban 27b35e1f72 feat(rbac): gate network + panel actions (manage-network/manage-panel)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:52:09 +02:00
boban 55c3975d53 feat(rbac): role enum + column + gates foundation (admin>operator>viewer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:42:16 +02:00
boban 3c25f097fa fix(security): close the TOCTOU in the symlink-safe status write (Codex review)
The first hardening pass created the update-phase.json temp with mktemp INSIDE ./run (the
container-writable bind mount) and reopened it by pathname (`> "$tmp"`). A container racing the root
writer could unlink that temp and drop a symlink in its place before the reopen, redirecting the root
write outside ./run. All THREE status-writers now build the temp at the repo ROOT — same filesystem as
./run (so the follow-up mv -f is an atomic, symlink-safe rename(2)) but NOT mounted into any container,
so it cannot be tampered mid-write: watch.sh run_write_json, update.sh set_stage, AND install.sh
set_stage (reachable as root directly and via update.sh -> exec install.sh). The transient /.phase.*
temps are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:38:50 +02:00
boban a6176341e2 fix(security): keep internal IPs out of shipped code and docs/ out of the public image
Audit leak-hygiene findings:
- The operator's real internal dev-VM/WireGuard IPs (10.10.90.136/.162/.165) were shipped in
  vite.config.js (hmr.host), lang hint/placeholder strings, a code comment and the CHANGELOG.
  vite HMR host is now env-driven (VITE_HMR_HOST, dev .env only); all other occurrences are replaced
  with RFC5737 documentation addresses (203.0.113.x). Tests updated in lockstep.
- docs/ is export-ignored from the git tree and named in promote.sh's refuse-to-publish list, but was
  never .dockerignore'd, so `COPY . .` baked the private docs (with private host/URL refs) into the
  public image, which the git-tree leak-guard never inspects. Added docs/ (and art/) to .dockerignore.
- /update.log (new repo-root update transcript) gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:25:10 +02:00
boban d7156ddc96 fix(security): harden the update path — signed sentinel, symlink-safe root writes, .env 0600
Security audit (12-angle adversarial workflow) findings on the container→host boundary:
- .env was created world-readable (cp of a 644 .env.example, never chmod'd) exposing the APP_KEY
  that decrypts the whole fleet's SSH vault. Now chown clusev + chmod 0600 before any secret write.
- The root updater followed attacker-planted symlinks in the container-writable ./run (update-phase.json,
  update.log) → arbitrary root file write. Status writes now go via a temp + rename(2) (never follow a
  link); the update transcript moved to the repo root (not the ./run bind mount).
- The generated UPDATE_HMAC_KEY was dead. The app now HMAC-signs the update-request marker
  (config/clusev.php update_hmac_key; DeploymentService) and watch.sh verifies it before running a
  root update, so a stray/limited write to ./run can't drive one (a full container compromise holds
  the key, so this is a bar-raise + integrity check; the standing guarantee is the marker only ever
  re-installs the trusted remote's code, never attacker code).
- force_kv/set_kv wrote unescaped values into a sed replacement — a & | or newline in an operator's
  domain/email/port could corrupt .env or inject a sed command. Values are now CR/LF-stripped and
  sed-escaped, and the HTTP port is validated numeric/in-range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:25:10 +02:00
boban 8a814337ff fix(install): validate release-image digests as ghcr.io/<owner>/<name>@sha256:<64-hex>
Codex R15 flagged that _clusev_ref_ok accepted malformed digest refs (short/non-hex,
which trigger a doomed pull instead of a build fallback) and refs carrying an embedded
newline in ANY segment (force_kv would then split .env, injecting an attacker-chosen key).
Anchor the check to an exact 64-hex sha256 digest and two ghcr.io path segments that each
start alphanumeric and otherwise contain only [A-Za-z0-9._-] — so no newline, "=",
whitespace or slash can appear anywhere in the ref. Malformed or tampered refs now fall
back to a local build and can never reach force_kv. Tests: +6 cases (short, non-hex,
path-traversal, ".." name, newline in digest, newline in owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:43:45 +02:00
boban 4af1633ca9 fix(install): harden release-image resolver — safe build fallback, ghcr digest allowlist, lint + CI coverage
Review found the build fallback could inherit a stale digest pin (compose build aborts), a slash-shaped/non-ghcr lock ref caused a doomed pull, env mode never persisted the pin (host restart used a nonexistent default tag), and the resolver test was unlinted/unrun in CI. Resolver now always leaves a coherent pin, validates ghcr.io/<owner>/<name>@sha256 refs only, exports the mode flag (drops the file-level shellcheck disable), and CI lints + runs the script unit tests.
2026-07-03 21:27:30 +02:00
boban ace3af5849 feat(install): pull promoted image on public installs via release-images.lock 2026-07-03 21:03:03 +02:00
boban a3cc563883 feat(install): resolve prod image from release-images.lock (pull vs build) 2026-07-03 21:01:26 +02:00
boban 383f2fe74a feat(release): remove the no-op channel selector + dead beta→stable promotion (single stable channel) 2026-07-03 20:43:15 +02:00
boban 10486672a5 ci: build+push prod images, gated auto-promote to public
Backbone sub-project #1, CI half (verified with actionlint; the runtime proof is
the first rc/stable tag push — these jobs cannot run locally).

- ci-staging.yml:
  - new `images` job (needs: test): buildx builds clusev-app (Dockerfile) +
    clusev-terminal (docker/terminal/Dockerfile) → private GHCR, tagged by
    version; then a framework smoke-boot (`php artisan route:list`) so a class
    stripped by export-ignore that is still reachable on boot fails before promote.
  - `deploy-staging` widened to any tag; pulls the CI-built private image
    (CLUSEV_IMAGE/CLUSEV_TERMINAL_IMAGE + CLUSEV_PULL=1) instead of building.
  - new gated `promote` job (needs: [test, images], if success + stable vX.Y.Z,
    no -rc): copy the exact image digests to the public GHCR, run promote.sh
    (leak-guard + export-ignore tree), write release-images.lock, push code+tag.
- promote-public.yml: repurposed as a guarded manual fallback mirroring the auto
  promote (image copy + lock + guard). Dropped the "beta channel" naming.
- promote-stable.yml: removed (no beta→stable step in a single-channel model).

Deploy inputs are GitHub vars/secrets (GHCR_OWNER, PUBLIC_GHCR_OWNER,
PUBLIC_REPO_SLUG, STAGING_*, GHCR_TOKEN, PUBLIC_REPO_TOKEN, PROMOTE_FORBIDDEN) —
never hardcoded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:34:00 +02:00
boban fff2d017be build: export-ignore the promote.sh test scripts too (they invoke the ignored script) 2026-07-03 20:06:14 +02:00
boban 4e4023f12d feat(release): collapse to a single stable channel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:00:50 +02:00
boban 176b132da9 chore(release): remove every 'gitea' reference from the public tree (comments, test fake-host, redundant ignore)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:54:20 +02:00
boban 54518c1f30 feat(release): string leak-guard in promote.sh + shell test 2026-07-03 19:49:31 +02:00
boban e02f4d036a build: export-ignore the full dev-release bridge + its tests from the public tree
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:47:01 +02:00
boban 72b82df7a1 fix(update): surface update failures instead of spinning forever
A dashboard-triggered update that failed on the host left the /update-progress
page spinning to a vague 10-minute timeout — no error was ever shown, because
nothing wrote a failure marker for the page to read. Fix: make failures visible
and prevent the credential-prompt hang class.

- docker/restart-sentinel/watch.sh: run update.sh under `timeout -k 30 1800` with
  its output tee'd to run/update.log; on ANY non-zero exit (update.sh or its
  exec'd install.sh) — and on the early compose-missing / updater-missing paths —
  write {"stage":"error"} to run/update-phase.json via write_update_error().
- update.sh: export GIT_TERMINAL_PROMPT=0 + GIT_HTTP_LOW_SPEED_* and wrap the pull
  in `timeout 300`, so a private-repo credential miss fails fast instead of
  hanging on a non-interactive prompt.
- DeploymentService::updatePhase(): whitelist the 'error' stage.
- update-progress.blade.php: add an error state (#js-status-error) + showError();
  the status-feed poll now stops and shows it on stage=error, marking the active
  phase red — instead of looping.
- lang/{en,de}/update.php: error_heading / error_hint / back_button.
- UpdateProgressTest: feed surfaces stage=error; page renders the error branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:58:32 +02:00
boban 6348d269a5 docs: fix stale first-login copy (random initial password, rotation optional)
The in-panel help "First login" section still claimed you log in with the
literal default password `clusev` and that "the panel forces a password change
immediately". Both are false since this cycle: the installer generates a RANDOM
password shown once in the closing summary, and EnsureSecurityOnboarded only
nudges (skippable), never forces.

- help/overview.blade.php (en+de): rewritten — sign in with the initial
  password from the installer summary; setting your own is recommended (a
  banner reminds) but not forced.
- lang/{en,de}/auth.php: default-password banner → "Initial password still in
  use" / "Initialpasswort noch aktiv".
- ResetAdmin.php: reset output "Einmal-Passwort" → "Neues Passwort".
- EnsureSecurityOnboarded.php: comment wording aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:50:35 +02:00
boban d08a8a3866 docs: rename the generated password "one-time" → "initial" (rotation is optional)
Password rotation is a nudge, not a wall (Install sets must_change_password=true
but EnsureSecurityOnboarded only prompts and is skippable), so calling the
generated credential a "one-time password" wrongly implied forced/single-use
rotation. Renamed the user-facing term everywhere:

- README + lang/{en,de}/accounts.php: "one-time password" / "Einmal-Passwort"
  → "initial password" / "Initialpasswort"; temp_intro "must change on first
  sign-in" → "prompted to change" / "sollte … geändert werden".
- Install.php $description: "Standard-Passwort (Zwangswechsel …)" → "zufälligem
  Initialpasswort (Wechsel empfohlen, nicht erzwungen)".
- Install.php / CreateUser.php comments aligned.

Kept "one-time backup codes" (2FA codes are single-use) and "shown only once"
(the summary is displayed once).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:43:09 +02:00
boban 657e659d76 docs: use the short `clusev` host-CLI aliases in the README
Operator-facing docs must show the short host commands, not the long
docker-compose invocation (same convention already enforced for the in-panel
help/versions/MOTD surfaces):

- Updating: `sudo ./update.sh` -> `sudo clusev update`.
- Recovery: `docker compose -f docker-compose.prod.yml exec app php artisan
  clusev:reset-admin` -> `clusev reset-admin`.

Add README.md to CommandShortcutsTest's DISPLAY_SURFACES and
UPDATE_COMMAND_SURFACES so the README is regression-guarded too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:26:42 +02:00
boban f3c5fee839 feat(shell): point Help entry at the online docs (docs.clusev.com)
The sidebar "Help" item, the command-palette entry and the `g h` shortcut now
open the public documentation site in a new tab instead of the in-panel help
page — no duplicated docs to keep in sync.

- config/clusev.php: add `docs_url` (env CLUSEV_DOCS_URL, default
  https://docs.clusev.com) so the docs host is configurable per install.
- sidebar + command-palette: Help href -> config('clusev.docs_url'),
  target=_blank rel=noopener.
- app.js: navigate() opens absolute http(s) URLs via
  window.open(_, '_blank', 'noopener'); go() resolves leader keys absent from
  CMDK_GO via the (config-driven) nav list, so `g h` follows the docs URL.

The in-panel /help route/component is kept (unlinked) as an offline fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:12:29 +02:00
boban 9f784ce97a chore: release 0.11.0-beta1
Web terminal (per-server + Clusev-host SSH), guided onboarding tour, terminal server search,
operator-set passwords; optional password rotation, 6-char minimum, random install password,
domain-free installer; host-key pinning + proxy-trust hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:26:34 +02:00
boban 0fbf379ae9 docs(readme): match the installer + auth changes (no domain prompt, random install password, web terminal)
- Quick start: the installer no longer asks for a domain — it installs over IP/HTTP and prompts only
  for the HTTP port + admin email; a domain (auto-HTTPS) is set later in the dashboard.
- The first admin now gets a RANDOM one-time password (shown once in the summary), not the literal
  'clusev'; rotation is recommended (a warning banner nudges) but no longer forced.
- add the web terminal to Features (per-server + Clusev-host SSH, host-key pinned); mention the
  first-run guided tour; point the domain/TLS section at the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:20:32 +02:00
boban 2a1ad6595b chore(install): don't prompt for a domain — install over IP, set it later in the dashboard
The panel domain is a dashboard concern now (System page → panel_domain override + Caddy
on-demand TLS), so the installer no longer asks for it. A fresh interactive install goes
straight to bare-IP/HTTP (prompts only for the HTTP port + admin email). The domain still
works everywhere it needs to: CLUSEV_DOMAIN=… preset for scripted installs is kept, and a
non-interactive re-run (update.sh) still preserves the existing .env domain; the closing
banner and the bare-IP warning now point the operator to Dashboard → System for the domain.

shellcheck clean; bash -n OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:14:29 +02:00
boban dc26dccaa3 feat(onboarding): first-run spotlight tour — dimmed backdrop, sidebar highlights, relaunchable
A guided tour for new operators: a dimmed+blurred overlay with an enterprise description card
(mono eyebrow "01 / 06", display title, body, progress bar, back/skip/next). A step with a
target spotlights the matching sidebar nav item (a box-shadow "hole" lights it while everything
else dims); welcome + finish are centred cards. On narrow viewports (sidebar hidden) it falls
back to centred cards.

- auto-opens once per account (users.onboarding_tour_completed_at is null → autostart); skip or
  finish calls Tour::markSeen() which stamps it, so it never auto-opens again.
- relaunchable any time from Settings (a client-side 'onboarding:start' window event — no DB change).
- @persist'd in the layout + a sessionStorage guard so a wire:navigate or fast reload right after
  dismissal can't re-open it before markSeen persists.
- nav-item now merges its attribute bag (data-tour passes through without duplicating class/href).

Browser-verified: auto-open → spotlight walks Dashboard/Servers/Terminal/Settings → skip stamps +
closes → navigation doesn't reopen → Settings relaunch works; zero console errors. 4 feature tests;
full suite 473 pass; Codex review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:00:19 +02:00
boban cc4cc3db4c security: pin the SSH host key in the terminal sidecar (fail-closed)
The ssh2 sidecar connected with no host-key verification, so a MITM could present its own key
and capture the (decrypted) SSH credential — the phpseclib fleet path pins via TOFU but the
sidecar did not. The resolve endpoint now returns the server's TOFU-pinned host key; the sidecar
verifies the presented key against it (crypto.timingSafeEqual) during the handshake, before any
credential is sent.

parseHostKeyPin distinguishes three states so a corrupted record can't silently unpin:
  null   → no pin (Clusev host / never-contacted server) → accept, TOFU first use
  false  → a pin is present but malformed/undecodable    → FAIL CLOSED (reject)
  Buffer → enforce the pin (reject a mismatch as MITM)

Verified in-browser against a real server: valid key connects + runs; a swapped key is rejected
as MITM; a garbage non-empty key fails closed — all before any credential leaves the sidecar.
Full suite 469 pass; Codex re-review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:33:53 +02:00
boban c3788f5851 security: generate a random install password instead of the literal 'clusev'
Rotation is optional now, so a well-known default ('clusev') would be a standing takeover
credential on a fleet-root console. The installer now creates the first admin with a random
Str::password(16), printed once on the CLUSEV_ADMIN_PASSWORD= line that install.sh already
surfaces in the closing banner. must_change_password still nudges a rotation, but even if the
operator keeps it, the secret is no longer guessable.

Verified: the printed password authenticates + differs per install; noop-when-admin-exists is
intact; Codex review APPROVED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:33:52 +02:00
boban fd4f6ceb0f chore: remove dead code — sidecar host-PTY path, node-pty, unused lang keys + component
The "Clusev host" terminal resolves to an SSH login now (resolve never returns kind:'host'), so the
sidecar's local-shell path was unreachable:
- docker/terminal/server.js: drop startHost(), the spec.kind==='host' branch, the node-pty require
  and HOST_SHELL/HOST_CWD constants — the sidecar only ever opens an SSH PTY.
- Dockerfile: drop the python3/make/g++ toolchain (only there to compile node-pty's native addon);
  package.json: drop the node-pty dependency. Leaner, faster image.
- compose (dev+prod): drop the now-unused TERMINAL_HOST_CWD env and the .:/workspace:ro mount.
- remove grep-confirmed-unused lang keys (settings 2FA/stub-tab keys, accounts twofa/cannot_remove,
  auth recovery_done/regenerate_confirm, common back/retry/more/loading, servers firewall/fail2ban
  unavailable variants) and the unused x-server-item Blade component.

Verified: lean sidecar rebuilds + the server terminal still connects/runs; full suite 467 pass; all
pages 200 with no raw-key leaks and no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:16:11 +02:00
boban e38409bf31 security: harden proxy trust + model mass-assignment + terminal-token log hygiene
Fixes from the security audit (the brute-force/rate-limiting angle matters more now that the
password policy is min(6)/no-complexity, so IP-keyed throttles must not be spoofable):

- bootstrap/app.php: trustProxies(at: '*') → trust only PRIVATE ranges in prod. Caddy reaches
  app:80 from the docker bridge (private IP) so it stays trusted, but a public/off-network source
  can no longer forge X-Forwarded-For to spoof request()->ip() and bypass the login/2FA/forgot
  throttles + the brute-force ban.
- TerminalSession + HostCredential: replace $guarded=[] with an explicit $fillable allowlist; add
  $hidden=[secret,passphrase] to HostCredential so it never serializes its credential fields.
- nginx /terminal/ws: access_log off — the single-use session token rode the query string into the
  access log.

Verified: full suite 467 pass; all panel pages 200 with zero console errors; server terminal still
connects + runs commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:16:10 +02:00
boban f3d2a9592e feat(auth): drop password complexity — minimum is now just 6 characters
Operator decision: password strength is the user's own responsibility. Removed the
mixedCase + numbers requirement from every password rule (Password::min(6) is all that
remains), so a plain 6-char password ("abcdef") is accepted. Dropped the
"upper/lowercase + a digit" clause from the hints (de + en).

Verified: a 6-char simple password validates; a 5-char one still rejects (length); full
suite 467 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 06:29:33 +02:00
boban 6f43b480b1 feat(auth): lower the password minimum from 12 to 6 characters
Operator decision — 12 was too long; 6 is acceptable and password strength is the user's
own responsibility. Changed Password::min(12) → min(6) in every place that validates a
password (password change, forgot/reset, settings profile, create-user) and updated the
visible hints (de + en). The upper/lowercase + digit complexity is unchanged — only the
minimum length was flagged.

Verified: a 6-char complex password ("Abc123") now validates; too-short and complexity-less
passwords still reject; the password page shows "min. 6"; full suite 467 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:19:41 +02:00
boban d08670270e feat(accounts): let the operator set a new user's password directly
The Add-account modal only ever generated a one-time password — there was no way to set
one. Add an optional password field (with a show/hide toggle):

- blank → previous behaviour: a strong one-time password is generated, the account is
  flagged must_change_password, and the clear text is revealed once.
- filled → that becomes the user's own login (validated min 12 / mixed case / digit);
  must_change_password stays false (no forced rotation) and nothing is revealed.

Browser-verified: the modal shows the password field + hint; both paths covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:29:15 +02:00
boban 00a9d8b9a3 feat(ui): shield-only 2FA indicator in the sidebar + terminal search from 2 servers
- sidebar: the "2FA on/off" text badge next to the user name was visually noisy. Replace it
  with a single status shield — a cyan shield-check when 2FA is on, a muted struck-through
  shield when off; the label moves to the hover/aria title so it stays reachable.
- terminal: the server-target search now appears once there is more than one server (was 5+),
  so it is there as soon as filtering helps; added a clear (×) button + an accent focus ring.

Browser-verified: sidebar shows just the shield (no text); search filters the rail (homelab →
only homelab-01) with a working clear button; both badge states render correctly. 465 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:20:46 +02:00
boban 2b1e98c5d4 fix(terminal): make the saved host-login state unmistakable in the modal
The stored secret is never echoed back (security), so on reopen the blank password field
read as "not configured" / "it forgot my password". The login DOES persist (single
HostCredential row, kept across reloads); only the modal was ambiguous.

- when configured, show a green status: "Configured as <user> — password is stored. Leave
  blank to keep it; enter a new one to change it." The Remove button + gear stay, so the
  operator can still change the password or switch access.

No behaviour change — set once, stays until changed/removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:08:50 +02:00
boban e1a886671b refactor(terminal): host login targets the local machine only — drop the HOST/IP field
The "Clusev host" terminal is for the LOCAL machine Clusev runs on. Letting the operator
point it at an arbitrary host/server makes no sense (that is what fleet servers are for)
and the pre-filled host.docker.internal read as a confusing mandatory magic value.

- the host is now FIXED to host.docker.internal server-side (HostShell::HOST), not a form
  field; the modal asks only for the login — user + password|key, and the port.
- the rail tile sub-label shows just the login user (the host is implied/local).

Browser-verified: modal has no HOST/IP field (user/port/auth/password only); saving shows
the user on the tile; opening the host reaches the real host sshd (a wrong password yields
an SSH auth error, proving the connection), zero console errors. 16 terminal tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:00:04 +02:00
boban 0ec9f3664a feat(terminal): explain the host-login HOST/IP default in the setup modal
The field is pre-filled with host.docker.internal — a Docker DNS name that resolves to
the machine the containers run on (the Clusev host) — but had no explanation, so it read
as a mandatory magic value. Add a one-line helper under the field: it's the default for
the Clusev host; leave it, or enter another IP/host. The field stays editable on purpose
(some setups want the host's LAN IP or a different machine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:52:13 +02:00
boban 142b531d45 feat(auth): make password rotation optional + correct the 2FA copy
The seeded-password rotation was forced before the panel was reachable. It is now a
nudge, not a wall: the prompt shows once per session and can be skipped, and a standing
warning banner keeps reminding while the default password is still in use.

- EnsureSecurityOnboarded redirects to the password page only when must_change_password
  AND the session has not set onboarding.password_skipped (set by PasswordChange::skip,
  which lands on the dashboard WITHOUT rotating — so the flag/banner persist).
- app layout shows a default-password warning banner while must_change_password is true.
- the metrics broadcast channel no longer gates on securityOnboarded() — a deliberately
  un-rotated (but authenticated) operator would otherwise lose realtime metrics;
  /broadcasting/auth still runs in the web group (guest = 403), so it stays auth-gated.
- 2FA was already optional (never enforced) — only the login copy was wrong: "2FA
  required/enforced" → "recommended/optional" (de + en).

Browser-verified (R12): login → skippable prompt → dashboard banner → free navigation
(no re-redirect); login copy correct; zero console errors. Full suite 465 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:47:17 +02:00
boban 83626352e2 feat(terminal): Clusev-host SSH login tile + server search; drop the inline hint
The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no
sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet
server — so it shows the real root@<host> prompt with full rights.

- HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets
  the operator set host/port/user + password|key. The clear-text secret is never echoed
  back; on edit a blank secret keeps the stored one, and switching auth method requires a
  fresh secret (no password silently reused as a key).
- open('host') mints a kind=host token only when a login is configured, else opens the
  setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted,
  internal-net only) for kind=host, or 404 when unconfigured.
- compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host
  sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly).
- rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line.

Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as
root runs commands, zero console errors; search filters; resolve returns the host spec.
15 terminal tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:46:55 +02:00
boban 707b031bd2 ci: bump actions/checkout + actions/setup-node to v5 (drop Node 20 runtime)
GitHub deprecated the Node 20 action runtime, so checkout@v4 and setup-node@v4 were
force-run on Node 24 with a warning. v5 of both targets Node 24 natively; their inputs
are unchanged (ref/repository/token/fetch-depth/path; the build's node-version stays
'20', that's the project runtime not the action runtime). setup-php@v2 is the current
major and unaffected. ci-staging runs only on tag pushes, so the warning clears on the
next tagged release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:55:52 +02:00
boban e3b5aa5902 fix(terminal): correct mobile sizing — fit after web-font load + open PTY at the visible grid
On a phone the terminal clipped on the right: FitAddon measured the column count before
JetBrains Mono finished loading (narrower fallback metrics → too many columns → the wider real
font then overflowed and the right edge was cut). And the PTY opened at a fixed 80x24, so the
remote shell's first screenful (MOTD/prompt) was generated at 80 columns and wrapped awkwardly
on a ~43-column viewport.

- refit immediately, on the next animation frame, and again on document.fonts.ready (font race)
- refit on the 'ready' frame before sending the size
- pass the browser's cols/rows on the WS URL; the sidecar opens the host PTY / SSH shell at that
  size (clamped 1..1000), so output is born at the visible width

Verified in-browser at 390px: PTY reports 43x23 (matches the box), no horizontal overflow, clean
wrapping, zero console errors; desktop server+host terminals and cross-origin rejection still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:08:12 +02:00
boban 05042c3582 fix(terminal): clean Clusev-host shell prompt — node@clusev:~ not node@<hash>:/workspace
The host terminal is a shell in the sidecar container; it opened in the read-only
repo mount (/workspace) with the prompt showing the random container id, so it read
like a stray container rather than "the Clusev host".

- give the sidecar a stable `hostname: clusev` (dev + prod) → prompt host is `clusev`
- land the host PTY in /home/node (a real, writable home) and set HOME to it, so the
  shell opens at ~ like a normal terminal instead of /workspace

Server (SSH) terminals are unchanged. Verified in-browser: prompt reads node@clusev,
pwd is /home/node, HOME=/home/node, no container hash / no /workspace; server terminal
still connects and runs; zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 06:31:49 +02:00
boban 07fe404ce9 feat(terminal): web terminal — per-server SSH + Clusev host PTY (xterm.js + node sidecar)
A dedicated /terminal page with a target rail: one terminal per fleet server (SSH)
plus one for the Clusev host itself. php-fpm cannot hold an interactive PTY, so a
small Node sidecar (ws + ssh2 + node-pty) bridges xterm.js to the PTY.

Flow: the Livewire page mints a single-use, 60s TerminalSession token and dispatches
it to an Alpine xterm island; the browser opens a same-origin WS (/terminal/ws,
proxied by nginx in dev / Caddy in prod to terminal:3000); the sidecar burns the
token at an internal resolve endpoint (shared-secret header, private network only)
for a connection spec, then opens an SSH PTY (decrypted credential, never sent to the
browser) or a local host PTY. Real shell = native Tab/Shift-Tab completion.

Security:
- token burned ATOMICALLY (single conditional UPDATE) — no double-open race
- resolve endpoint guarded by hash_equals shared secret, exempt from CSRF + PanelScheme
  host enforcement (private net only), returns decrypted creds over the internal net
- sidecar enforces same-origin on the WS upgrade (hostname match, port-agnostic)
- single-use 60s tokens, swept daily so the table can't grow unbounded

Verified in-browser (R12): host + server terminals connect, accept keyboard input,
run commands, Tab-complete; cross-origin WS rejected; zero console errors. 9 feature
tests cover minting, the resolve spec, single-use burn, expiry, and locked creds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:21:10 +02:00
boban 8dfc11fd5d fix(versions): keep the changelog series stable on reload (0.10 -> 0.1 bug)
The series rail synced ?series=<key> via Livewire #[Url] with a bare numeric key like
"0.10". Livewire type-juggles that to 0.1 (the trailing zero is lost), so reloading
while viewing the 0.10 series silently switched to 0.1 and rewrote the URL. Prefix the
series key with a non-numeric "v" (?series=v0.10) so it round-trips as a string; the
rail label still shows the dotted "0.10". Surfaced now that a 0.10 series exists.

Browser-verified: selecting 0.10 -> ?series=v0.10, and it survives a reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 02:15:41 +02:00
boban d8da46151e chore: release 0.10.0-beta1
Server-Details resource-history graph (CPU/RAM/Disk over time, selectable range +
hover tooltip, range persisted in the URL), nameable SSH keys, and two hardening
fixes (SSH-root-login self-lockout guard, fail2ban systemd-journal backend). See
CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 02:03:40 +02:00
boban 1697a5d7c2 feat(metrics): persist the history-chart range in the URL
Clicking a range now writes ?range=<key> (replaceState — no history spam) and the
chart restores it on init, so reloading the SAME page keeps the chosen range. Opening
the page fresh (no param) still defaults to 24h.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:59:39 +02:00
boban fbd9c7e257 fix(metrics): move the history chart x-axis labels below the plot
The y-axis labels spanned the whole wrapper (inset-y-0), which now included the
x-axis label row — so the "0" tick and the "−1 h"/"jetzt" labels overlapped at the
bottom. Wrap only the plot + y-axis in the relative box and render the x-axis labels
as a separate row beneath it, so "0" sits on the chart's bottom edge and the time
labels are clearly below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:55:49 +02:00
boban b2c6de64da refactor(metrics): interactive history chart — smooth, area, tooltip, instant ranges
Reworks the Server-Details history graph from a static Livewire-rendered SVG into a
self-contained Alpine island fed by a JSON endpoint, addressing the feedback:

- Smooth Catmull-Rom curves instead of angular segments.
- Continuous line with an area gradient fill; the line breaks ONLY on a real outage
  (delta-t > 2.5x the bucket), not on every empty bucket — no more dropouts.
- Hover crosshair + tooltip (time + CPU/MEM/DISK at the point).
- Instant client-side range switching (1h/24h/7d/30d) via
  GET /servers/{server}/history.json — no Livewire round-trip.
- X-axis labels moved below the plot (no longer overlapping the 0 gridline).

MetricHistory::series() now returns only non-empty buckets plus from/now/bucket. The
chart wrapper is wire:ignore so the parent's 10s poll never clobbers the Alpine state;
the island self-refreshes every 60s. The old Livewire range logic is removed.

Codex review: clean (auth-gated route, range clamped, numeric-only JSON, guarded hover
bindings, R3/R4-compliant). Browser-verified: smooth curves, area fill, working tooltip
+ crosshair, instant range switch, zero console errors. 446 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:51:24 +02:00
boban 6227505528 feat(metrics): persistent resource-history graph on the Server-Details page
A CPU/RAM/Disk history graph over selectable ranges (1h/24h/7d/30d) on the server
detail page, backed by a persisted time series (the live 15s view stays cache-only).

- metric_samples table (server_id cascade, cpu/mem/disk %, load, sampled_at indexed).
- clusev:sample-metrics (scheduled every minute, mirrors clusev:wg-sample): persists
  one row per server from the cached live reading, prunes beyond --retention (30d).
- MetricHistory::series() buckets + averages samples for a window; empty buckets stay
  null so the chart shows gaps, not fabricated values.
- Servers/Show: range selector + a gap-aware SVG line chart (theme-token strokes,
  R3-compliant, mirrors the dashboard chart). historyRange is clamped via setRange()
  and the updatedHistoryRange() hook (it's a public, client-settable property).

Codex review: fixed a right-edge bucket off-by-one, clamped percentages on write, and
guarded the public range property against direct client values. Browser-verified: the
chart renders with data, range buttons, legend and gaps, zero console errors. 447
tests green, pint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:31:27 +02:00
boban 940c4c80fa fix(hardening): fail2ban — pin the sshd jail to the systemd backend
Verified live against a real Debian 12: the hardening actions work end-to-end
(root + password self-lockout guards refuse correctly, auto-updates and ufw enable,
disabling password auth persists and blocks password logins). One gap surfaced: on
a minimal image without rsyslog the distro-default fail2ban sshd jail (logpath
/var/log/auth.log) fails to start — "Have not found any log file". Pin the sshd jail
to `backend = systemd` (reads the journal) via a jail.d drop-in on enable:
log-source-agnostic and valid on every supported (systemd) target. fail2ban then
comes up active with the sshd jail reading the journal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:14:06 +02:00
boban d56bdadd14 feat(ssh-keys): name an added SSH key so it is identifiable
The "add SSH key" modal had no name field — keys appeared only by comment +
fingerprint, so additional keys weren't tellable apart. Add a name field; the name
becomes the key's OpenSSH comment (what the server list shows), for both generated
and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at
64 chars) so a crafted name cannot inject a second authorized_keys line — proven by
test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:53:49 +02:00
boban 7ec2c065bb fix(hardening): guard against root-login self-lockout
Disabling SSH root login (PermitRootLogin no) severs Clusev's OWN access when it
connects AS root — afterwards neither key nor password reaches root. The
password-auth toggle already guards against self-lockout; the root toggle did not.
Refuse to disable root login while the active credential's username is root, with a
clear message: add a non-root sudo user + key and switch Clusev to it first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:41:50 +02:00
boban cf70317534 docs(readme): drop stale static version; add OS-support matrix + real requirements
The README banner showed v0.9.40 while the badge showed v0.9.58 — static fields
that go stale and disagree. Removed the per-release version from both the badge row
and the banner SVG (the release version lives in tags/changelog, not a frozen
image). Added a "Supported operating systems" matrix (Debian/Ubuntu · RHEL · SUSE
full; Arch partial; Alpine metrics-only; anything else metrics-only) and real
minimum requirements (CPU/RAM/disk for the panel host; nothing on managed servers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:35:35 +02:00
boban ab728efc6f fix(versions): dev box lists BOTH its source and the public repo
Follow-up to the Projekt-card change: the dev/release-control box must list its
actual update source (e.g. private Gitea) AND the public repo — the previous commit
showed only the source on dev. Replaced the single project link with projectLinks():
the public repo is always listed (validated slug, canonical fallback); the dev box
prepends its source when it is a real URL (a garbage/hostless source is dropped, never
an empty link). Staging/stable still list the public repo only — no private leak.

Codex review: no leak path on non-dev; hardened the dev source against a hostless URL.

Verified: 433 tests green (dev-both / non-dev-only / malformed-slug / garbage-source),
pint clean, /versions shows both links on the dev box with zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:51:28 +02:00
boban 8e77f31f00 fix(versions): non-dev installs only link the public repo on the Projekt card
The Versions "Projekt" card linked config('clusev.repository') — the per-install
update source, which on the dev box is the private Gitea. A staging/stable install
must never surface a private source. Now only the dev/release-control box shows its
actual source; every other install links the public open-core repo only, built from
the public slug with a canonical fallback (a malformed or full-URL slug can never
produce a private/garbage link). The update SOURCE (ReleaseChecker) is unchanged —
this governs only the displayed link.

Codex review: hardened the slug to reject non-owner/repo values; the release_controls
gate is the dev-box invariant (it also gates the dev-only /release page, and a staging
box's CLUSEV_REPOSITORY is the public repo anyway).

Verified: 432 tests green (incl. dev / non-dev / malformed-slug cases), pint clean,
/versions loads 200 with zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:41:18 +02:00
boban 05c34b81ed fix(update): make the update-progress screen survive a browser refresh
The self-update progress screen reset its elapsed timer to 0 and dropped the
phase checklist back to step one on every browser refresh — and the long image
build made it look like the update had restarted. Root causes: the timer and
phase state lived only in page JS (Date.now() at page load), and a
30s-before-page-load staleness guard rejected the real phase feed whenever a
stage had run longer than 30s (which the image build always does).

- DeploymentService: requestUpdate() now also writes a best-effort
  run/update-started.json {at,from}. New updateStartedAt()/updatePhase() expose
  the server-side start + current macro-stage; updatePhase() requires a positive
  timestamp so a corrupt or stale 'done' can never drive a premature finish.
  clearUpdateRequest() also removes the start marker.
- routes: /update-status.json delegates to updatePhase(); /update-progress
  passes startedAt/initialPhase/hasFeed. A stage is trusted only when tied to
  THIS update's start (startedAt) and written at/after it — a leftover stage from
  a previous run can no longer resume or trigger a premature completion.
- update-progress.blade.php: the elapsed timer is anchored to the server-side
  start (counts the real elapsed across a refresh); the checklist resumes at the
  live stage; the live feed is authoritative (corrects any time-heuristic
  overshoot) and the heuristic stays off once a real stage is known.

Verified: 28 UpdateProgressTest cases + full suite (426) green, pint clean, and a
headless browser load+reload mid-update keeps the timer counting and the phase in
place with zero console errors. Codex review: two premature-completion edge cases
found and fixed (timestamp validation + start-anchored freshness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:05:54 +02:00
boban e39a5939f2 docs(changelog): condense to user-facing entries; collapse internal tooling
The changelog mixed user-facing changes with dev-only maintainer tooling
(the release pipeline, CI fixes), reading as overly detailed/unprofessional.
Establish the convention up front — user-facing changes only; internal work
lives in git history — and apply it:

- Collapse the dev-only release-tooling versions 0.9.59–0.9.68 into a single
  terse 0.9.68 "Intern" entry (kept parseable so the Versions page still lists
  the running version).
- Trim 0.9.58 to its user-facing parts (GitHub update-check, public address).
- Reduce 0.9.57 to the user-facing release-channels note.

Verified the Versions-page changelog parser still yields clean nodes
(0.9.68 -> Intern, 0.9.58, 0.9.57, ...) with no leaked translation keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:37:31 +02:00
boban 8a7d91bd00 chore: release 0.9.68 — public promotion + yank controls
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:04:23 +02:00
boban 1eb4c4fef7 fix(release): cache public-tags reads + tighten yank tag validation (review)
- PromotionService::publicTags() is called on every Release render incl. the 5s
  page poll; the anonymous GitHub tag API is rate-limited (60/h per IP). Cache it
  120s so the panel never gets rate-limited to empty. (Test flushes cache in setUp.)
- yank.yml: replace the loose shell-glob tag guard with an anchored grep regex so
  a garbage/space/metachar tag dispatched straight from the GitHub UI is rejected
  before reaching git push --delete (the panel already validates tag ∈ publicTags).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:01:36 +02:00
boban 67a69f3189 feat(release): publish + yank UI, strings, audit labels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:28:49 +02:00
boban ea33797236 feat(release): deploy-to-public, promote-to-stable, yank actions (R5 + audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:26:14 +02:00
boban 84f84cbb03 ci: yank.yml — workflow_dispatch to delete a tag on the public repo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:13:02 +02:00
boban 7c5b4cf222 feat(release): PromotionService — dispatch promote/yank workflows + read public tags
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:08:11 +02:00
boban d3b2ad2f23 chore(release): 0.9.67-beta1 2026-06-23 02:50:04 +02:00
boban 5c620bc0c7 chore: release 0.9.66 — pipeline shows build readiness only (drop test-server step)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:48:05 +02:00
boban 44d0072c1a refactor(release): drop the test-server step — pipeline is build readiness only
'Staging' is not a single server: any beta-channel server pulls a release itself,
so a per-server 'deployed' step in the dev dashboard was arbitrary + contradicted
that model. The pipeline now tracks only the global build facts of a beta —
Tag (Gitea) → Mirror (GitHub-private) → CI. Removes PipelineStatus::testStep, the
test-server Setting/input/saveTestServer, and the now-orphaned lang keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:46:17 +02:00
boban f26aa66cf5 chore(release): 0.9.65-beta1 2026-06-23 02:33:04 +02:00
boban 9b2d71e1f8 chore: release 0.9.64 — CI fully green (pint style fixes)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:30:33 +02:00
boban f82555469a style: pint --test fixes (CI pint step now reachable after the test fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:29:59 +02:00
boban 770bfec97f chore: release 0.9.63 — green CI (Vite manifest before tests)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:45 +02:00
boban 25271717c2 fix(ci): build the Vite manifest before php artisan test
Full-page tests render @vite layouts; without public/build/manifest.json (gitignored,
absent on a fresh CI checkout) they 500. Move npm ci + npm run build ahead of the
test step so the manifest exists. Fixes the red CI on every v* tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:20 +02:00
boban bdee34ef8f chore: release 0.9.62 — live release pipeline status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:16:40 +02:00
boban fca75bc0a3 fix(release): neutralise private slug in tests + render step descriptions (review)
- PipelineStatusTest: the private mirror slug 'boksbc/clusev-staging' was hardcoded
  in this tracked test (tests/ ships to the public repo) — exactly the leak the
  design forbids. Neutralise to an example slug 'acme/staging'. Add the missing
  HTTP-error degrade tests (GitHub 5xx, GitHub throwing, test-server unreachable →
  'unknown', never throws) — the core robustness guarantee was untested.
- Live rail: render the per-step description (the built $sub array was dead code)
  and colour the live state by status (online/warning/offline).
- saveTestServer: refresh() the pipeline cache so a changed test-server URL is
  reflected immediately, not after the 15s TTL.
- .env.example: document the dev-only release/pipeline keys (commented, no values).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:13:26 +02:00
boban 787e48d8fd feat(release): live pipeline rail + test-server input on the Release page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:55:14 +02:00
boban 0ac819e4ad feat(release): wire the live pipeline + test-server setting into the page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:49:01 +02:00
boban 070c6646f3 feat(release): PipelineStatus — live tag/mirror/CI/test-server status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:43:00 +02:00
boban 06e9fcbad7 chore: remove internal/AI docs from the repo (keep local)
The GitHub-private staging repo is a full push-mirror of Gitea, so export-ignore
(which only protects the public repo via git archive) does not strip these from
the mirror. Untrack the internal/AI-collaboration docs — CLAUDE.md, rules.md,
handoff.md, kickoff-prompt.md and docs/ (specs, plans, runbooks, handoffs) — and
gitignore them so they stay on the dev box but never reach Gitea, the mirror, or
any shipped image. The public repo was already clean via export-ignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:09:04 +02:00
boban 665c9f93bd chore(release): 0.9.61-beta4 2026-06-23 01:03:27 +02:00
boban 8acc6ab6ba chore(release): 0.9.61-beta3 2026-06-23 00:59:34 +02:00
boban d80e44086a chore(release): 0.9.61-beta2 2026-06-23 00:54:50 +02:00
boban 788d38fab5 chore(release): 0.9.61-beta1 2026-06-23 00:49:06 +02:00
boban 164d17e707 chore: release 0.9.60 — redesigned Release page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:32:44 +02:00
boban 6a284caa44 design(release): rebuild the Release page in the Tactical Terminal system
Replace the bland version + four plain buttons with a composed layout: a current-
version hero (mono version, channel + source), tactile bump tiles showing the
current → target transition with a Beta-tag badge (continue-beta highlighted
accent), styled running/last-pushed states, and a vertical pipeline rail
(Gitea → GitHub-privat → CI → Staging). Tokens-only, responsive 375/768/1280,
German, no emoji; the R5 confirm flow + component contract are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:32:23 +02:00
boban 25516ad323 chore: release 0.9.59 — Phase B1 deploy-to-staging control
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:20:24 +02:00
boban d502756b66 fix(release): genuine rollback-path test + install/gitignore hardening (review)
- serve-request.test.sh: the push-fail case pointed origin at a dead https remote,
  so _precheck's fetch failed first and the rollback path in _do_stage was never
  reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote
  (pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs;
  also assert the tree is restored clean.
- clusev-release.sh: |\| true on the version extraction (defensive under pipefail).
- install.sh: make the release-unit install best-effort (if/then/warn) like the WG
  block so a systemctl failure cannot abort the installer.
- .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep.
- docker/release/README.md: dev-watcher runbook (the manual end-to-end gate).
- ReleaseBridgeTest: assert a rejected target writes no request file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:16:22 +02:00
boban baecb0c6d5 fix(release): add missing confirm-modal + staged-banner lang keys
The shipped Release UI references release.confirm_title, confirm_body,
confirm_action and staged_label, but neither lang/de/release.php nor
lang/en/release.php defined them, so the R5 confirm modal and the staged
success banner leaked raw keys (R9/R17). Add the four keys to both locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:01:06 +02:00
boban c696950f25 feat(release): localized strings + audit labels for staging releases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:56:09 +02:00
boban 76c1d46d12 fix(release): gate the /release route on the flag (spec gating triple)
Register the /release route only when config('clusev.release_controls') is
true — the first of the spec's three gating layers (route + component mount +
sidebar). The route file is re-included per request when uncached, so the flag
is honoured; mount() abort_unless(...) still covers the cached-route case.

The gating test re-evaluates routes/web.php after toggling the flag (the file
is read once at boot, so a runtime config()->set() alone cannot register the
route), mirroring a fresh request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:48:17 +02:00
boban 4b68b8dc04 feat(release): flag-gated /release page + Deploy-to-Staging via host bridge
Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:39:47 +02:00
boban 58a94d1e9a feat(release): systemd watcher units + dev run-mount + flag-gated install
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:28:27 +02:00
boban d4f973044b feat(release): host bridge script — cut+push a staging beta from a request
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:20:00 +02:00
boban 54b41591b9 feat(release): ReleaseBridge — write staging request, read host result
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:14:34 +02:00
boban 05c98a7b9e feat(release): ReleasePlanner — proposed beta targets from the current version
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:07:41 +02:00
boban 39ccf0d539 docs(plan): Phase B1 — Deploy to Staging implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:03:56 +02:00
boban 366d237cb8 docs(spec): Phase B1 — Deploy to Staging (dashboard release control)
First sub-project of Phase B: a dev-only Release page with a Deploy-to-Staging
button that cuts a beta (bump config version, commit, tag vX.Y.Z-betaN, push to
Gitea) via a host release bridge — same isolation as the WireGuard bridge, so the
container never holds git/token credentials. Gated behind CLUSEV_RELEASE_CONTROLS.
B2 (public promotion via GitHub API + vault token) is a separate later spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:52:34 +02:00
boban 628c9fdbb0 fix(release): guard on internal-doc presence only; genericise .env.example IP
The earlier leak guard grepped the published tree for private URL strings, but
promote.sh / promote.test.sh contain those very strings (patterns + fixture), so
it tripped on its own source and would also ship them. Switch the guard to
internal-doc presence (CLAUDE.md/rules.md/handoff.md/kickoff-prompt.md/docs) — a
reliable pre-fix-tree signal that embeds no private identifier. Genericise the
dev VM IP in .env.example (APP_URL/REVERB_HOST) to localhost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:19:19 +02:00
boban 51b8572d98 fix(release): leak guard + export-ignore + env default (review fixes)
The actual file changes for the leak-safety hardening described in a94efb0
(that commit only captured the art/ asset move):
- promote.sh: leak guard refusing to publish a tree carrying a private identifier
  (git.bave.dev / boban/clusev / clusev-staging) or an internal doc; negative test.
- .gitattributes: export-ignore kickoff-prompt.md (leaked the VM IP).
- README: reference the moved art/ SVGs (docs/ is export-ignored).
- .env.example: comment out CLUSEV_REPOSITORY so the public-repo config default
  applies (a present-but-empty value would disable the update check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:16:23 +02:00
boban 437221788d fix(versions): discriminate release caches by repository + assert the UA value
- ReleaseChecker: mix a short hash of clusev.repository into the tag + changelog
  cache keys so a cached value can never be reused across a host/repo change (the
  promotion pipeline re-points CLUSEV_REPOSITORY between the private mirror and the
  public repo).
- Strengthen the GitHub User-Agent test to assert the value (Clusev-Panel), not
  mere presence — Guzzle always sets some UA, so the old check was a tautology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:15:36 +02:00
boban a94efb0b0d fix(release): leak-safety hardening from adversarial review
- promote.sh: add a leak guard that refuses to publish a tree still carrying a
  private identifier (git.bave.dev / boban/clusev / clusev-staging) or an internal
  doc (CLAUDE.md, rules.md, handoff.md, kickoff-prompt.md, docs/). This makes
  promotion safe even for a tag cut BEFORE the export-ignore fixes — such a tree is
  refused instead of leaking the dev/staging repos. Negative test added.
- .gitattributes: export-ignore kickoff-prompt.md (internal bootstrap doc; leaked
  the VM IP + infra topology).
- README: move the banner/architecture SVGs out of the export-ignored docs/ into
  art/ so the public README images are actually published.
- .env.example: comment out CLUSEV_REPOSITORY — a present-but-empty value defeats
  the config default (env() returns ''), which would silently disable the public
  update check. Left unset, the public-repo default applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:15:36 +02:00
boban b00e7a49fd docs: move maintainer release-pipeline guide out of the public README
The release-pipeline + one-time-wiring section named the private staging runner
label and described internal setup — not public-facing. Move it to
docs/release-pipeline.md (export-ignore, never published) so the public README
stays clean and no private repo name leaks via the published tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:43:38 +02:00
boban ccabc1cfa6 chore(release): point public-facing URLs at github.com/clusev/clusev — 0.9.58
Default update source, README (badge/clone/home), the console + source-view
branding, and the CHANGELOG compare links now reference the public repo. Private
URLs remain only in the gitignored .env (install.sh derives CLUSEV_REPOSITORY
from the clone origin). Bump 0.9.58.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:41:52 +02:00
boban a92eb8182b chore(release): keep internal docs out of the public repo
promote.sh copies a release tree with git archive, which honours export-ignore.
Mark CLAUDE.md, rules.md, handoff.md and docs/ as export-ignore so internal dev
notes (and the Gitea URL they contain) never reach the public repo; drop the
stock CHANGELOG.md export-ignore so the public Versions page can fetch it. Extend
promote.test.sh to assert internal files are excluded and CHANGELOG.md is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:41:52 +02:00
boban 586fac7132 feat(versions): support GitHub host in the update check
ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a
GitHub-hosted public repo would never resolve a release. Centralise the repo-URL
parsing and branch by host: GitHub uses api.github.com for tags and
raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires);
Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so
all host logic lives in one place.

Make the update-check tests hermetic: they now set clusev.repository themselves
(neutral host) instead of depending on the ambient .env — so they pass in CI with
an empty .env.example — and add GitHub-host coverage for tags + changelog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:41:52 +02:00
boban 44313d5ac5 chore: release 0.9.57 — release-promotion phase A (CI/CD scaffolding)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:20:07 +02:00
boban 8e9499780e fix(release): review polish — promote idempotency, CI hardening, docs
- promote.sh: fail fast before mutating the public tree if the tag was already
  promoted (avoids a half-commit left on the runner on re-dispatch).
- ci-staging.yml: pass STAGING_PROJECT_DIR via env + :? guard instead of a
  ${{ }} expression in run: (clears the template-injection lint, fails loudly
  on misconfig).
- README: PUBLIC_REPO_URL was never read by any workflow — keep only the slug.
- set-repository-url.test.sh: chmod +x for mode parity with promote.test.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:18:44 +02:00
boban 4aa55b7124 ci: workflow_dispatch promotion to public (beta) + beta-to-stable
Promotion workflows for the release pipeline. promote-public publishes a tag to
the public repo's beta channel; promote-stable re-tags a finalised beta as a
stable vX.Y.Z. Both bound to the 'production' environment (required reviewer),
reading PUBLIC_REPO_SLUG/PUBLIC_REPO_TOKEN. Tree copy via scripts/promote.sh
excludes .git/.github so private CI never reaches the public repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:18:32 +02:00
boban e2dc9b17a4 docs: release pipeline (3-repo promotion, channels, wiring)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:10:03 +02:00
boban c4f90b0830 ci: test on v* tags, optional self-hosted staging deploy on betas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:03:14 +02:00
boban 79bd08b0d8 fixup(release): promote.sh --allow-empty so identical-tree stable promotion still tags
Stable re-tags the same source commit as the last beta, so the extracted tree
is byte-identical and `git commit` would abort on "nothing to commit" under
set -euo pipefail, leaving the stable tag uncreated. Use --allow-empty and add
a promote.test.sh case that promotes a second identical-tree tag into the same
pub repo to guard the regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:59:24 +02:00
boban 0dd7564ab0 feat(release): promote.sh — clean per-release tree copy to the public repo
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:53:44 +02:00
boban c6e759a3b4 fixup(install): align set-repository-url write-back with plan if/else structure
Restore the plan's if-grep / replace / else-append skeleton in the
CLUSEV_REPOSITORY write-back. The replace branch deletes existing
line(s) then appends once so duplicates collapse to a single entry,
keeping scripts/set-repository-url.test.sh green (the plan's verbatim
in-place sed leaves two identical lines and fails the duplicate-line
assertion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:46:59 +02:00
boban ee111bfa9a feat(install): derive CLUSEV_REPOSITORY from the clone origin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:43:25 +02:00
boban a1386b0533 refactor(config): repository URL is env-driven (no private URL in tracked files)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:35:59 +02:00
boban 06b5600641 docs(plan): release-promotion phase A implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:33:03 +02:00
boban 30034a564b docs(spec): full release model — channels, dashboard buttons, two phases
Capture the maintainer's release workflow: dev-dashboard buttons drive the
pipeline (Deploy to Staging / Deploy to Public / Promote to Stable). Beta =
vX.Y.Z-betaN on staging, Stable = vX.Y.Z on public; public serves both channels.
Public promotion triggered via the GitHub API with a deploy token in the
encrypted vault. Decomposed: Phase A = git/CI/CD + config-URL infra (buildable
now, URL-agnostic); Phase B = the dashboard release-control UI (own spec, after
A + the repos exist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:34:21 +02:00
boban 70da2718fb docs(spec): clarify URL handling — public default in config, private URLs only in .env
Per the maintainer: dev/staging may hold any URL (in their gitignored .env);
the public repo's tracked files carry only the public URL. So config/clusev.php
defaults to the public URL, private (Gitea/GitHub-private) URLs live solely in
gitignored .env, and install.sh derives CLUSEV_REPOSITORY from the clone origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:22:34 +02:00
boban e2b4a968c9 docs(spec): 3-repo release promotion (Gitea → GitHub private → public)
Design for promoting Clusev dev → staging → public across three repos with
GitHub Actions CI, a manual gate before public, and a clean public history.
Core: no repository URL in tracked files — derived from the clone origin, kept
in gitignored .env per environment, so the public repo never exposes the
private/Gitea URLs and the URLs plug in later when the GitHub repos exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 07:03:24 +02:00
boban 4ca2ed96d5 feat(brand): source-view comment + console branding with self-XSS warning (v0.9.56)
- A friendly HTML comment in the page source (both layouts, before <head>) —
  ASCII "CLUSEV", what it is, and the project link — for anyone who View-Sources.
- A styled console banner printed on load (every page, incl. login): the CLUSEV
  wordmark, a one-liner, the repo link, AND a self-XSS security warning. The
  warning earns its place: a Linux-server control panel is a prime target for
  "paste this into your console" social engineering, so the notice sits exactly
  where that attack happens. console styling is best-effort and never throws.

Verified: /login 200, the console logs the brand + warning + link, the source
carries the comment, no page errors; 374 tests pass; Vite build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:19:46 +02:00
boban 8914f5f746 fix(files): responsive file rows + restore button cursor (v0.9.55)
- File manager: on tablet widths the lg table grid squeezed the name column
  (minmax(0,1fr)) to near-zero behind the perms/size/modified columns + wide
  action buttons, so the filename was unreadable/overlapped. The rows now render
  as a stacked card below xl — name on its own full-width line, a folded
  perms·size·modified line and the actions beneath — and only switch to the
  table grid at xl (tightened columns). Mobile tidied up to match.
- Restore the pointer cursor: Tailwind v4's preflight makes buttons use the
  default cursor, so clickable controls (folder names, every button) didn't show
  a hand. Added a base rule giving enabled button/[role=button]/summary/label
  the pointer cursor.

Verified the row at desktop / tablet / mobile widths (name readable at every
breakpoint, table only when wide). 374 tests pass (the Livewire test renders the
blade), Tailwind build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:06:32 +02:00
boban 359aa86aea fix(files): binary file content no longer breaks the Livewire snapshot (v0.9.54)
Opening a binary / non-UTF-8 file in the file manager threw "undefined is not
valid JSON". The editor bound the raw file bytes to its public $content
property; Livewire JSON-encodes the component snapshot, and invalid UTF-8 makes
PHP's json_encode return false, so the client received an empty response and
JSON.parse("undefined") failed (the trace in mergeNewSnapshot/deepClone).

Fixed at both layers: FleetService::readFile blanks the content when it detects
binary/non-UTF-8 (returns the binary flag instead), and FileEditor::load only
binds content for non-binary, non-oversize files. Binary/oversize files show
their existing notice; the snapshot is always valid UTF-8.

Tests: load blanks binary content (keeps the snapshot valid) and keeps valid
text content. 374 pass, Pint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:49:05 +02:00
boban 7a6d20cbed fix(ui): add wire:key to list rows — stops "pending is not defined" (v0.9.53)
Lists rendered loop rows without a wire:key. On re-render Livewire's morph
matched rows by position and cloned them, dropping the inline Alpine x-data
(modalTrigger) scope on the buttons — the console threw "Alpine Expression
Error: pending is not defined" (and a follow-on "undefined is not valid JSON"
as the snapshot merge broke). Reported in the file manager; the same pattern
existed across the app.

Added a stable wire:key to every loop whose rows carry inline Alpine / x-modal-
trigger state: files (entries + breadcrumbs), services, the release-channel
selector, settings (users, webauthn keys, login-protection bans) and the server
detail page (hardening, firewall rules, SSH keys, fail2ban exceptions). Keys use
a stable identifier (id / fingerprint / md5 of the natural key), not the loop
index.

372 tests pass (the Livewire tests render these blades, so a broken key would
fail them); /system, /settings, /files load 200 with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:44:03 +02:00
boban 91be170e78 fix(dashboard): readable audit labels in the recent-events panel (v0.9.52)
The /audit page already rendered human-readable action labels (v0.9.48), but the
Dashboard's "recent events" panel still showed the raw codes (wg.set-endpoint,
deploy.update_request). It now uses the same AuditEvent::action_label + is_error
styling, so failures show red with an alert icon — consistent with the audit page.

Test: dashboard recent events render the readable label, not the raw code. 372
pass, Pint clean, dashboard loads 200 with no console errors and no raw codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:27:13 +02:00
boban 4b2aee3941 fix(versions): show what's-new (v-prefixed ref) + sidebar update badge (v0.9.51)
Two fixes the operator asked for:

- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
  ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
  panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
  test masked it with a wildcard fake; tightened with Http::assertSent.

- The sidebar now shows a "1" badge on the Version item when an update is
  available, so an update is visible on every page, not only the Versions page.
  Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
  (no network in the sidebar); a scheduled clusev:check-update (every 30 min,
  anonymous/read-only) keeps the cache warm so the badge is accurate even before
  the operator opens the Versions page.

Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.

Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:19:27 +02:00
boban c6597be59e feat(audit): paginate the log + DB-wide search (v0.9.50)
The audit log loaded the latest 50 entries and filtered them in memory, so it
couldn't grow and search only spanned those 50. Now it is paginated (25/page)
with the page deep-linked in the URL via Livewire WithPagination (?page=), and
the search runs in SQL across the whole history — matching actor / action code /
target / server name AND the readable labels (a term is translated to the action
codes whose localized label contains it, so "Endpoint geändert" still finds
wg.set-endpoint). Pagination control mirrors the changelog's windowed pager.

Tests: 25/page with page 2 overflow, search resets to page 1, search filters in
SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint
clean, /audit loads 200 with no console errors, audit lang parity 62/62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:58:27 +02:00
boban 6d9dcb9529 feat(versions): show the available release's changelog before updating (v0.9.49)
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator
couldn't see what an available update contained until after applying it. Now,
when behind a published release, a "What's new in vX.Y.Z" panel shows the
changelog of the available version(s) — fetched from the remote CHANGELOG.md at
the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with
the shared changelog parser and filtered to versions newer than installed.

Degrades gracefully: a failed fetch yields an empty preview and never blocks the
update button. Refactored releases() to a reusable parseChangelog().

Tests: preview lists only newer versions + renders their entries; no preview
when current. 362 pass, Pint clean, /versions loads 200 with no console errors,
versions lang parity 51/51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:48:37 +02:00
boban e154f7d92f feat(audit): readable action labels + failures surfaced (v0.9.48)
The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.

Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.

Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:41:48 +02:00
boban c89b28e1ef ux(wg): endpoint field is host-only (no apparent double port) (v0.9.47)
The server config showed a "Port" and an "Endpoint" that repeated the same
:51820, so it looked like the port had to be entered twice. It never did (since
0.9.43 a bare host gets the listen port appended) — but the UI didn't say so.
Now the field is labelled "Public endpoint (host)", the placeholder shows only
the host (port stripped via Str::before), and the hint explains the port is
taken from the listen port automatically; a different port is only needed for
port-forwarding (host:port). Display/copy only — no behaviour change.

Pint clean, /wireguard server tab loads 200 with no console errors, lang
parity 89/89, 354 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:25:44 +02:00
boban 645d7dd4a4 fix(wg): traffic header shows live peer totals; QR-name limit made explicit (v0.9.46)
- The Traffic panel header ("Empfangen/Gesendet") drew from the sampled
  throughput, which is 0 until the minutely sampler accrues deltas — so a peer
  with live traffic (e.g. 244 B) showed "0 B" in the header. It now sums the
  live per-peer rx/tx counters, so the header always matches the peer rows. The
  chart below stays windowed throughput (fills from the sampler).
- import_hint reworded to state plainly that the WireGuard phone app ALWAYS
  prompts for a tunnel name on a QR scan (app behaviour, not changeable); the
  .conf download is the path to an automatic name (filename = tunnel name).

Tests: header sums live peer counters. 354 pass, Pint clean, /wireguard 200 with
no console errors, lang parity 89/89.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:59:26 +02:00
boban 60e1edc0d5 feat(wg): configurable DNS + Server/Peers tabs + faster offline (v0.9.45)
Three things the operator asked for on the WireGuard page:

- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
  cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
  stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
  the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
  Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
  traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
  SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
  active peers re-handshaking well within it, so no flapping; a disconnect now
  shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
  (auto-appended), no need to repeat the listen port.

Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:48:46 +02:00
boban 920dfb02bf feat(wg): compact QR + meaningful download filename (v0.9.44)
- QR: 0.9.42 over-enlarged it; bring the show-once peer QR back to a balanced
  ~240px (still crispEdges on a white quiet-zone frame) so it scans on a phone
  without dominating the modal.
- Download filename now becomes a useful tunnel name on import: build it from
  the endpoint host + peer name (e.g. "10.10.90.165-laptop.conf") instead of the
  generic "clusev-wireguard.conf" — WireGuard apps name a tunnel after the file.
  A QR scan can't carry a name (the app prompts for one); the import hint now
  explains both paths.

Tests: download-filename cases (host+peer, fallback); 349 pass, Pint clean,
/wireguard loads 200, lang parity 83/83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:25:41 +02:00
boban dc6d2fae59 fix(wg): endpoint auto-gets the listen port when given as a bare host (v0.9.43)
If the operator entered an endpoint without a port (e.g. "10.10.90.165"
instead of "10.10.90.165:51820"), that value was written verbatim into every
peer config. WireGuard requires the endpoint as host:port, so the WireGuard app
rejected the QR/file import as "keine gültige WireGuard-Konfiguration" — the
real cause of the invalid-config errors (the QR-size fix in 0.9.42 was secondary).

Normalise host-side: append the listen port when the endpoint has no colon, in
both _write_server_conf (setup — covers the dashboard and interactive paths) and
_set_endpoint (later changes). Verified: bare host -> host:port, host:port left
as-is, DNS host -> host:port; write-bridge smoke test still passes; shellcheck
clean. setup_endpoint_hint (de/en) now documents the host:port format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:11:25 +02:00
boban 9bb9f2561b fix(wg): peer QR is now scannable on a phone (v0.9.42)
The show-once peer config QR rendered at 176px (~2.7px per module) with
anti-aliasing on. A WireGuard config QR is dense (~57 modules), so a phone
camera misread it and the WireGuard app rejected the result as "keine gültige
WireGuard-Konfiguration". Render it large (up to ~300px) on a white framed
quiet zone with shape-rendering:crispEdges, so modules stay sharp and ~4.6px
each. The Download button (.conf import on a PC) remains as the alternative.

Verified: old-vs-new render comparison (2.7 → 4.6 px/module, crisp modules);
Tailwind compiles the arbitrary shape-rendering property; /wireguard loads 200
with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:06:42 +02:00
boban 7794e98997 fix(wg): write-bridge handler no longer dies before replying (v0.9.41)
The host handler `clusev-wg.sh serve-request` aborted under `set -euo pipefail`
BEFORE writing run/wg-result-<id>.json, so the dashboard waited ~30s and showed
"Keine Antwort vom Host" even though the systemd watcher was running correctly.
The write-bridge had therefore never actually worked on a real deployment.

Two root causes, both fixed:

1. Field extraction: each `id/action/name/endpoint/port/subnet` value is pulled
   with `grep -oE | head | sed`. A field that is empty or absent (an "auto"
   endpoint at setup; gate/add-peer/remove-peer payloads that carry no
   endpoint/port/subnet) makes grep fail → with pipefail the whole pipeline
   fails → `set -e` aborts the handler before _write_result. Appended `|| true`
   to every extraction so a non-match yields "" instead of killing the handler.

2. Action dispatch: gate-up/gate-down/gate-ssh-on/off and set-endpoint/port/
   subnet (and remove-peer) called helpers that run require_setup, which `die`s
   (exit) when the tunnel isn't configured yet — aborting serve-request before
   the reply. Each handler now runs in a subshell `( ... )` so an internal exit
   becomes a caught non-zero and still yields a clean failure result.
   (add-peer/setup were already isolated via their $(...) command substitution.)

Verified end-to-end: all ten actions now write a result for minimal/empty-field
payloads instead of timing out. New smoke test docker/wg/serve-request.test.sh
(root-bypassed, no WireGuard needed) guards both regressions. shellcheck clean,
348 PHP tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:53:25 +02:00
boban 20aec565ed docs: enterprise README for Gitea — branded hero + architecture diagram
Rebuild the README around the product's "Tactical Terminal" design system
(dark graphite, signal-orange, cyan, mono type) so it reads as a professional,
self-hosted enterprise product instead of a plain text file:

- docs/assets/clusev-banner.svg — hero banner with the Clusev wordmark, tagline,
  brand chips, and a live "fleet" node-graph (control-plane hub → server cards
  with the online/warning/offline status triad + CPU bars and animated data
  pulses). Self-contained: the real product fonts (Chakra Petch + JetBrains Mono)
  are embedded base64 so Gitea's <img>-rendered SVG shows pixel-correct type.
- docs/assets/clusev-architecture.svg — Operator → Control plane → Fleet diagram
  (HTTPS/WS · Reverb in, SSH+SFTP · phpseclib out), same brand language.
- README.md — centered hero, brand-matched shields badges, anchor nav, a
  "why" three-up, a Features table, the architecture diagram + a tech-stack
  table, and the existing install / domain-TLS / updating / security-recovery
  content kept verbatim (now with a WireGuard feature row). All graphics are
  external SVG files (Gitea strips inline <svg>); relative paths resolve on the
  default branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:52:00 +02:00
boban 9c0d390365 refactor(wireguard): remove dead code + DRY the WG surface (no behavior change)
Verified dead-code + duplication cleanup over the WireGuard feature surface
(found via an audit workflow, every item adversarially re-verified by grep):

Dead code removed:
- lang keys not_configured_title / not_configured_body (both locales) — orphaned
  when the unconfigured empty state became the in-page setup form (setup_title /
  setup_intro). 0 references anywhere. de/en parity kept (83/83).
- WgTraffic::series() 'buckets' return key — written, never read by blade/route/test.
- blade $fmtB byte-formatter — byte-identical duplicate of the top-level $fmtBytes
  already in scope; collapsed onto $fmtBytes.

DRY / readability (behavior-preserving):
- Index: PEER_NAME_RE constant (was the same regex literal at 5 sites), portInRange()
  + clampWindow() helpers (window default now self::WINDOWS[0]), openConfirm() helper
  (5 confirm-modal openers shared one dispatch), onFlag() helper (2 apply handlers).
- WgBridge: validPort() helper (port check was duplicated verbatim).
- clusev-wg.sh: _write_server_conf() shared by both setup paths (the byte-identical
  server bring-up was copy-pasted); single CIDR_RE constant for the 3 subnet checks;
  fixed a stale header comment that still claimed SSH is "NEVER matched" (the v0.9.40
  ssh-lock can match 22); documented the defence-in-depth re-validation in _set_*.
- WgStatus: documented server.pubkey is part of the /wg-status.json contract (kept).

No DB/migration changes (peer_name write-only column left intact — needs a
coordinated migration). 348 tests pass, shellcheck clean, Pint clean, R12 verified
in both the configured + unconfigured states (200, no console errors, no leaked
keys, traffic formatter renders 1.5 MB / 200 KB correctly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:33:41 +02:00
boban 2b245c3d3a feat(wireguard): SSH lock + peer config download (v0.9.40)
Two dashboard additions to the WireGuard page:

- Peer config download: the show-once new-client view gets a "Download"
  button next to the QR code (streams clusev-wireguard.conf) plus an
  inline hint explaining QR-on-phone vs download-on-PC import.

- SSH lock (port 22): an optional toggle in WireGuard settings that walls
  off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the
  host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo +
  wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over
  the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot
  unit's ConditionPathExists=wg0 and an in-function self-guard keep a
  broken tunnel from ever applying the DROP; ssh_gate_apply re-validates
  the subnet shape and fails open on a corrupt wg.env. Strong lock-out
  warning shown inline AND in the confirm modal (recommend a backup peer).
  New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears
  both gates. Panel gate and SSH gate are independent toggles.

Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host
whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector
now reports gate.ssh. Adversarial lock-out review: all 8 failure modes
refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:25:19 +02:00
boban 94425f93e8 fix(wg): remove SSH hint from setup form + time out a non-responding host (no endless spinner)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:38:43 +02:00
boban a064e8eeb8 chore: release 0.9.38 — WireGuard first-time setup from the dashboard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:16:40 +02:00
boban 8e05cf62ec feat(wg): set WireGuard up from the dashboard (setup form + bridge action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 08:09:35 +02:00
boban 01778bde80 feat(wg): non-interactive setup action for the dashboard write-bridge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 08:06:00 +02:00
boban 6f9091d3fa docs(wg): SP2 Phase 5 plan — first-time setup from the dashboard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:04:21 +02:00
boban 2d12978616 chore: release 0.9.37 — WireGuard settings + gate from the dashboard (SP2 phase 4, complete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:03:05 +02:00
boban 858a00955a fix(wg): single audit per confirm action (no descriptor double-audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:00:26 +02:00
boban 7ccefa5890 feat(wg): gate toggle + endpoint/port/subnet settings from the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:53:29 +02:00
boban 7ff02c4e68 feat(wg): set-endpoint/set-port/set-subnet host actions + serve-request branches 2026-06-21 00:49:55 +02:00
boban 95a5811544 docs(wg): SP2 Phase 4 plan — settings + gate from the UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:48:11 +02:00
boban e006de4962 chore: release 0.9.36 — WireGuard peer management from the dashboard (SP2 phase 3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:45:10 +02:00
boban b1387bd654 fix(wg): umask 077 in serve-request + UMask in unit — no 0644 race on key files (security review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:42:07 +02:00
boban ee8f2bac91 feat(wg): add/remove peers from the dashboard (show-once config + QR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:36:26 +02:00
boban ed5506e558 feat(wg): WgBridge — app side of the write-bridge (validate + request + result) 2026-06-21 00:32:02 +02:00
boban a6bb8b7da4 feat(wg): clusev-wg-request watcher (path+service) + install wiring
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:30:12 +02:00
boban 695b33cf37 feat(wg): serve-request write-bridge + peer/gate cores (host-side validation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:28:34 +02:00
boban f91d8ed834 docs(wg): SP2 Phase 3 plan — peer management + write-bridge
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:24:44 +02:00
boban 037d285148 chore: release 0.9.35 — WireGuard traffic history + scheduler (SP2 phase 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:13:30 +02:00
boban 0c9aa12aab feat(wg): traffic-history SVG chart + window selector on /wireguard 2026-06-21 00:07:18 +02:00
boban e1fb24203d feat(ops): add a scheduler service (schedule:work) — runs wg-sample + prune-audit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:05:14 +02:00
boban 49f70c0c9f fix(wg): WgTraffic up = throughput delta (not raw tx) — consistent with down
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:04:07 +02:00
boban 130e1aeaf6 feat(wg): WgTraffic — bucketed down/up throughput series 2026-06-21 00:02:35 +02:00
boban 9e6f3775b7 feat(wg): clusev:wg-sample command (sample peers + prune) + schedule 2026-06-21 00:00:06 +02:00
boban ae4937654d feat(wg): wg_traffic_samples table + model 2026-06-20 23:59:11 +02:00
boban eff836a1fb docs(wg): SP2 Phase 2 plan — traffic history + SVG chart
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:56:51 +02:00
boban 16b854ae50 chore: release 0.9.34 — WireGuard dashboard live-status (SP2 phase 1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:44:22 +02:00
boban 96349808e2 chore(wg): drop unused wireguard lang keys (server_key, transfer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:41:18 +02:00
boban e640a96ca0 feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:35:58 +02:00
boban 9b835e9dbe feat(wg): WgStatus reader + auth-gated /wg-status.json route 2026-06-20 23:32:44 +02:00
boban 9aabd7fce5 feat(wg): install + enable the WireGuard status collector timer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:30:47 +02:00
boban 3d73489717 feat(wg): clusev-wg-collect timer+service — 5s status collection 2026-06-20 23:29:21 +02:00
boban c164c0aec3 feat(wg): clusev-wg.sh collect — emit run/wg-status.json for the dashboard 2026-06-20 23:27:58 +02:00
boban dfdb0ce732 docs(wg): SP2 Phase 1 plan — live-status dashboard page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:26:15 +02:00
boban 19fe1b8915 docs(wg): SP2 design — full WireGuard management in the dashboard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:21:45 +02:00
boban bc82cdaff5 chore: release 0.9.33 — WireGuard gate + clusev wg CLI (SP1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:59:07 +02:00
boban 287f962745 harden(wg): validate peer name charset in add-peer/remove-peer (final review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:57:59 +02:00
boban 0fa11bd0ea style(install): quote set_stage "done" arg to silence shellcheck SC1010
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:52:50 +02:00
boban fa7e1c428e docs(wg): manual runbook for the WireGuard gate (SP1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:50:54 +02:00
boban c9556bffbf feat(wg): WireGuard help topic (registration + DE/EN content + test) 2026-06-20 22:46:04 +02:00
boban 0342d4c6e0 feat(wg): wire 'clusev wg' into the host CLI wrapper 2026-06-20 22:42:16 +02:00
boban 51c9702dcf feat(wg): install wireguard-tools/qrencode + enable the gate unit (inert by default) 2026-06-20 22:38:00 +02:00
boban 1e9834fc0d feat(wg): clusev-wg-gate.service — boot/docker-restart gate persistence 2026-06-20 22:35:25 +02:00
boban 8caa78b2b3 fix(wg): harden clusev-wg.sh — read EOF, awk ENVIRON, endpoint + wg.env guards (review)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 22:33:37 +02:00
boban dd964a2e8b feat(wg): clusev-wg.sh — WireGuard gate + peer CLI (host) 2026-06-20 22:25:40 +02:00
boban f154ee8aaf docs(wg): SP1 implementation plan — WireGuard gate + clusev wg CLI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:20:51 +02:00
boban 084c21a869 feat(update): real phase progress feed + deep-linkable changelog series/page
Update-progress phases were a pure time guess, so a fast update sat on "fetch"
then snapped all four green at once. Now the host updater publishes its real
macro-stage and the page tracks it:

- update.sh / install.sh write the current stage (fetch|build|restart|migrate|
  done) to run/update-phase.json — best-effort (|| true, never aborts an update).
- new public GET /update-status.json serves that stage (whitelisted) to the page.
- update-progress.blade.php drives the checklist from the feed in REAL order
  (fetch → build → restart → migrate), falling back to the time heuristic when no
  feed is present, and completes on the feed's 'done' (or the version flip, with a
  25s grace fail-safe). Like the 502 fix, the live experience lands one update
  after this ships (the page shown DURING an update is the old version's).

Also: Versions changelog series + page are now #[Url]-synced (?series=&page=),
so a series/page is shareable and survives reload / back-button.

Tests: /update-status.json (null / whitelisted / rejected stage), the page polls
the feed, and the changelog deep-link reads ?series=&page=.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:08:41 +02:00
boban 552417fd4b feat(versions): grouped + paginated changelog browser (no endless scroll)
Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.

Also fixes, surfaced by an adversarial review of the change:
- icon: add the missing 'chevron-right' Lucide path — the accordion disclosure
  caret and the pagination "Next" button were rendering an empty <svg>.
- blade: wire:ignore.self on each <details> so a user's open/closed toggle is not
  reset by an unrelated re-render (autoCheck / check-updates / in-update poll); a
  series/page change still re-defaults to first-open via a fresh wire:key.
- blade: 44px touch targets (series pills + pagination) below lg (R7).
- i18n: localised 'Other' series label; pluralised series_count (no "1 Releases").
- Index: render() is the single clamp authority for changelogPage.

Index.php: groupBySeries() + pageWindow() + selectSeries/gotoChangelogPage; render
builds the series list, resolves the active series, paginates. New DE+EN keys.
VersionsChangelogTest covers grouping order, default series, paging, clamp, the
installed-series cue, and the chevron-right icon regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:47:45 +02:00
boban 05150bf89f fix(update): wait for actual new version before redirect (no more 502)
The update-progress page declared completion on two consecutive 200s from
the /up health route. But the OLD container keeps answering /up 200
throughout the minutes-long rebuild, so the page redirected prematurely
back into the teardown → 502 / white screen.

Now the page polls a lightweight public /version.json probe and finishes
only when the running version has actually moved past the pre-update one
(passed as ?from=), or after an observed down→up cycle for a same-version
redeploy. The 10-minute timeout + manual-reload fallback are unchanged.

- routes/web.php: add public GET /version.json; sanitise+pass ?from= to the view
- Versions/Index.php: include the installed version as ?from= in the redirect
- update-progress.blade.php: version-gated completion (fromVersion/sawDown)
- UpdateProgressTest: probe endpoint, embedded baseline, from-sanitisation, no /up poll

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:02:21 +02:00
boban 37cb449af5 chore: release 0.9.29 — changelog accordion, responsive update card, server link, pro phase labels 2026-06-20 20:45:25 +02:00
boban 9bb349dd9c feat(dashboard): link the active server name + IP to its detail page
When a server is active, the server name and IP status-pill are now clickable
links to that server's detail page using wire:navigate. When no server is active,
both display as plain text to maintain readability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:41:59 +02:00
boban a1b37751a0 feat(versions): professional update phase labels, responsive update card, changelog accordion
Also update UpdateProgressTest assertions to match the new phase label strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:40:35 +02:00
boban 542ad1a4d8 chore: release 0.9.28 — graceful update page + checkbox/2FA-badge UI fixes 2026-06-20 20:26:50 +02:00
boban df5d9e2112 harden(update): drop inline spinner style (R4), server-side return-path sanitise
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:22:40 +02:00
boban d2f70900ff fix(update): graceful Livewire-less /update-progress page — no more 502/whitescreen on update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:14:11 +02:00
boban ffc17966ef fix(ui): unify the 2FA status badge into one x-two-factor-badge component
Replace three divergent 2FA indicator styles (cyan x-badge in users list,
custom inline-flex chip in settings header, plain mono text in sidebar) with a
single x-two-factor-badge component using the cyan x-badge + shield icon style
and the canonical settings.two_factor_on/off lang keys throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:07:59 +02:00
boban 001f44d959 fix(ui): themed x-checkbox component (accent, visible check) for login-protection + remember-me
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:04:47 +02:00
boban f0210c302b docs: spec — WireGuard panel gate + clusev wg CLI (SP1)
Optional host-level WireGuard gate for the Clusev panel: install.sh installs
wireguard-tools (off), a 'clusev wg setup/up/down/status/add-peer/remove-peer'
host CLI sets up wg0 + gates TCP 80/443 to the WG subnet via a dedicated
CLUSEV-WG-GATE iptables chain off DOCKER-USER, with 'clusev wg down' as the
escape hatch and SSH/WG-UDP never gated. Hardened after an adversarial review:
exact ordered firewall rules, persistence skips if wg0 is down (no reboot
brick), down isolates Clusev's chain, setup idempotency, precise install
wiring (CLUSEV_DIR exec + install_host_watchers). SP1 of 2; dashboard
status/traffic is SP2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:48:47 +02:00
boban 77d50d44e5 chore: release 0.9.27 — Anmeldeschutz (control-plane brute-force IP ban) 2026-06-20 18:58:24 +02:00
boban 6c81ea7fd1 harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test 2026-06-20 18:53:52 +02:00
boban cabad0e770 harden(auth): scope whitelist purge to active bans, cover unbanAll, hoist ip 2026-06-20 18:20:19 +02:00
boban 7b4215d055 feat(auth): Anmeldeschutz settings tab + unban confirm flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:13:12 +02:00
boban 96f86e2761 feat(auth): clusev:unban CLI + host wrapper escape hatch 2026-06-20 18:07:43 +02:00
boban 01867dba09 harden(auth): capture login IP once, assert 2fa-failed audit 2026-06-20 18:06:11 +02:00
boban 968d8c48c9 feat(auth): feed BruteforceGuard from login + 2FA failures, audit them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:54 +02:00
boban 2936af82bd feat(auth): guests-only BlockBannedIp middleware + 403 page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:45:16 +02:00
boban 7645241a7a feat(auth): ValidIpOrCidr validation rule 2026-06-20 17:42:06 +02:00
boban df32275fd9 test(auth): BruteforceGuard record/ban/unban/cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:40:40 +02:00
boban 49d5c0aed3 harden(auth): BruteforceGuard review fixes (hit count, mask bounds, once-guard, mapped loopback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:39:01 +02:00
boban ce98fbdba0 feat(auth): BruteforceGuard with inet_pton CIDR exemptions 2026-06-20 17:29:00 +02:00
boban 1f7b06c576 feat(auth): BannedIp model + migration 2026-06-20 17:26:28 +02:00
boban 9756f9d463 docs: implementation plan — control-plane brute-force ban (Anmeldeschutz)
9-task TDD plan: BannedIp model, BruteforceGuard (inet_pton CIDR + cache),
ValidIpOrCidr rule, guests-only BlockBannedIp middleware + 403 page, Login/2FA
failure hooks + audit, clusev:unban CLI, Anmeldeschutz settings tab with R5
confirm-modal unban. Spec erratum: middleware is appended (not prepended) so
Auth::guest() resolves for the guests-only check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:20:52 +02:00
boban fe8c25c824 docs: spec — control-plane brute-force IP ban (Anmeldeschutz)
App-level persistent IP ban for Clusev's own login (distinct from the remote-fleet
Fail2banService): BannedIp model, BruteforceGuard service, guests-only BlockBannedIp
middleware, Login/2FA hooks + audit, a new Anmeldeschutz settings tab, clusev:unban CLI.
Hardened after an adversarial multi-lens review: documented IP trust model (Caddy sole
proxy, prod-only trustProxies), guests-only enforcement so an authed operator is never
locked out and can self-unban, current-IP self-whitelist, concrete cache strategy,
inet_pton CIDR matching, R5 confirm modals, errors.php for the 403 strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:05:52 +02:00
boban d58073086c chore: release 0.9.26 — 2FA backup code as its own view 2026-06-20 16:42:30 +02:00
boban 4ea4050c80 fix(auth): R15 review — restore one-time-code autofill, memoise pendingUser in verify
- backup field: autocomplete=off -> one-time-code (backup codes ARE one-time codes;
  matches the main challenge field, the rest of the app, and the spec; restores
  password-manager OTP autofill that the earlier polish broke)
- both verify() use the trait's memoised $this->pendingUser() instead of a fresh
  User::find(), saving a redundant query on the failed-attempt re-render (Pint dropped
  the now-unused User import in TwoFactorBackup)
- spec: align trait backing-state visibility note to the implemented 'private'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:19:40 +02:00
boban 7c1a32518d i18n(auth): drop orphaned 2FA recovery hint strings 2026-06-20 15:47:26 +02:00
boban 8a8232a355 test(auth): cover TOTP+key challenge branch; clarify mount comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:46:37 +02:00
boban 67ce2de538 feat(auth): split backup code out of the main 2FA challenge view
Mount now redirects key-only users without a secure context straight to
the dedicated backup view. The main challenge form renders only for TOTP
users; a subordinate button links to two-factor.challenge.backup instead
of embedding the field inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:41:19 +02:00
boban 4e32f76d1b polish(auth): distinct backup page title, autocomplete off, empty-code test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:38:38 +02:00
boban e8bbe21b78 feat(auth): dedicated backup-code 2FA view + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:31:32 +02:00
boban 0765293123 refactor(auth): tighten trait visibility, return type, @mixin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:28:36 +02:00
boban 1fef9e61fe refactor(auth): extract CompletesTwoFactorChallenge trait 2026-06-20 15:24:09 +02:00
boban f581529164 i18n(auth): add 2FA backup-view strings 2026-06-20 15:21:52 +02:00
boban 8ec3b0b69a docs: implementation plan — 2FA backup-code own button + view
Task-by-task TDD plan for Spec 1: shared CompletesTwoFactorChallenge trait,
new TwoFactorBackup component + guest route + view, main-view redesign
(form gated to TOTP users, backup button, mount->backup redirect for
key-only-no-secure-context), i18n keys, and full R12/R15 verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:12:04 +02:00
boban 0f5d14a252 docs: spec — split 2FA backup code into its own button + view
Spec 1 of 2 for the 2FA challenge rework. Moves the backup (recovery) code
off the combined challenge screen into a dedicated /two-factor-challenge/backup
route + view, behind a 'Backup-Code verwenden' button; the main screen shows
only the primary factor. A key-only user without secure context (http + bare IP)
is redirected straight to the backup view. Shared rate-limit/login logic is
extracted into a CompletesTwoFactorChallenge trait so both views hit one bucket
set. IP-vs-domain factor routing is already correct (WebauthnService::available)
and unchanged. Spec 2 (auth-failure -> fail2ban hard IP ban) is out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:01:39 +02:00
boban b619b320c2 chore: release 0.9.25 — clusev host CLI + short commands + help tab-URL 2026-06-20 14:01:45 +02:00
boban 5967b7b7e5 fix: shell-escape the install dir when generating the clusev CLI (R15 Codex)
A checkout path containing shell syntax ($(), quotes) could otherwise inject
code into /usr/local/bin/clusev, which runs as root via 'sudo clusev update'.
Render the path through printf %q into an unquoted assignment; add a test that
a hostile path does not execute injected code.
2026-06-20 13:50:19 +02:00
boban 8d2b7087a9 feat: short clusev commands in versions panel, lang strings, MOTD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:18:25 +02:00
boban 437a22592b feat: help tab-URL + Commands/CLI topic + short command copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:06:45 +02:00
boban 580c72a59d feat: clusev host CLI wrapper for short stack commands 2026-06-19 22:56:54 +02:00
boban 503f4d7fdf docs: implementation plan for clusev host CLI + short commands + help tab-URL 2026-06-19 22:45:05 +02:00
boban 83bd42f16a docs: spec for clusev host CLI, short commands, help tab-URL 2026-06-19 22:37:37 +02:00
boban 1d9dbdfc9b chore(release): v0.9.24 - in-panel help page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:22:35 +02:00
boban d1fb93ffe3 feat(help): remaining topics (updates, servers, sessions, email, audit, recovery)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:20:58 +02:00
boban d60f97413f feat(help): security topic - 2FA access paths + extension note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:16:37 +02:00
boban d0275db308 feat(help): domain-tls topic with the reverse-proxy setup guide
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:15:39 +02:00
boban 28c28df868 feat(help): in-panel help page scaffold + overview topic
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:14:15 +02:00
boban ca693219bf docs(plan): implementation plan for the in-panel help page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:06:08 +02:00
boban 60295a0aea docs(spec): in-panel Help page design + drop product name from external-proxy hint
Spec for a bilingual /help page (left topic nav like Settings) covering all
settings, a generic reverse-proxy setup guide, and the 2FA access-path note
(security key only over the HTTPS domain; backup code on the bare-IP/HTTP path;
TOTP works everywhere). Also generalize the external-TLS hint copy (no product
name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:02:25 +02:00
boban a709ef772c fix(assets): drop Vite preload hints (silence 'preloaded but not used')
With one CSS + one JS entry the <link rel=preload> sits next to the matching
stylesheet/script and adds no value, but it triggers Chrome's 'preloaded but
not used' console warning (amplified by DevTools 'Disable cache', which
double-fetches the CSS). Disable preload tag generation; stylesheet and script
still load normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:50:54 +02:00
boban c020af4c09 fix(webauthn): add security-key hint so passkey managers defer
Even with authenticatorAttachment=cross-platform, Bitwarden's extension
intercepted navigator.credentials.create and offered to save a passkey. Add the
WebAuthn L3 'hints: [security-key]' to both registration and assertion options;
browsers and passkey managers that honor hints step aside and let the hardware
key prompt through. Injected into the client payload only (the session copy used
for server validation is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:30:52 +02:00
boban 5ebee9b4a5 fix(webauthn): target a hardware security key (YubiKey), one-touch second factor
Registration set no authenticatorSelection, so the browser offered the
passkey-manager 'save passkey' flow (Bitwarden) instead of prompting for the
YubiKey. Now request a cross-platform, non-resident, presence-only credential:
authenticatorAttachment=cross-platform (roaming key, not a platform/manager
passkey), residentKey=discouraged (non-discoverable second factor — what keeps
passkey managers out), userVerification=discouraged (single touch, no PIN).
Login assertion also uses discouraged so it is: insert key, one tap, done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:23:01 +02:00
boban 5abf984df2 fix(webauthn): enable security keys behind an external TLS proxy
WebauthnService::available() keyed on request()->isSecure(), which is false in
external-proxy mode (the forwarded https scheme isn't trusted without
TRUSTED_PROXY_CIDR) — so hardware keys were locked even though the panel runs
over HTTPS on the domain. Gate on being reached via the active domain instead
(always HTTPS through the front door); the bare-IP recovery host stays locked.
A core security feature no longer depends on an optional proxy setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:11:20 +02:00
boban a61feb1bef fix(deploy): apply Caddyfile changes on update (dir mount + recreate)
A single-file bind mount of the Caddyfile kept the original inode, so a
git pull that replaces the file never reached a running caddy, and up -d does
not recreate it for a content-only change — Caddy fixes (e.g. the 0.9.18
WebSocket fix) silently never deployed. Mount the docker/caddy directory
instead (reflects the live file) and force-recreate the caddy container on
every install/update so Caddyfile changes reliably take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:57:42 +02:00
boban 92969eb0dc fix(tls): reverb WebSocket + honest TLS status behind an external proxy
- reverbClient() now returns wss://<domain>:443 when the active domain is being
  served (the HTTPS front door), instead of deriving wss://host:80 from a request
  that looks like plain HTTP:80 behind an external TLS proxy. Bare-IP recovery
  stays plain ws.
- Caddy no longer redirects the /app/* /apps/* WS paths to https when the upstream
  already terminated TLS (X-Forwarded-Proto: https) — that redirect broke the
  WebSocket in external-proxy mode.
- The 'TLS active' status card no longer claims Let's Encrypt issuance in
  external-proxy mode; it states TLS is provided by the upstream proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:49:25 +02:00
boban 4bcdafbeff fix(tls): force https URL generation for the active domain (external-proxy mode)
Behind an external TLS proxy, Clusev's own Caddy only honors the upstream's
X-Forwarded-Proto when TRUSTED_PROXY_CIDR is set. Without it the app saw http,
asset() emitted http:// URLs, and the HTTPS page's CSP ('self') blocked them —
the panel loaded with no CSS/JS over the domain. PanelScheme now forces the URL
root + scheme to https://<domain> whenever the active domain is being served,
regardless of the (possibly untrusted) request scheme. The bare-IP recovery
path returns earlier and stays on HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:35:06 +02:00
boban 7d35f3abac fix(system): surface a failed restart sentinel write instead of a stuck state
Mirror the update button (0.9.15): requestRestart() now returns bool and
restartNow() shows an error toast (pointing at sudo ./update.sh) when the
sentinel can't be written, instead of a 'restarting' state that never resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:10:28 +02:00
boban 703207c03c fix(deploy): build prod image with host clusev uid/gid so sentinels are writable
The prod image baked the app user at uid 1002, but install.sh chowns the
bind-mounted ./run to the clusev host user. On a host where clusev != 1002 the
app process could not write the restart/update sentinel — the dashboard button
showed 'läuft' while nothing happened on the server. Pass APP_UID/APP_GID =
HOST_UID/HOST_GID into the prod build so the in-image app user matches clusev
and ./run is writable. Also surface a failed sentinel write as an error toast
instead of a stuck 'running' state (requestUpdate now returns bool).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:45:20 +02:00
boban 07020a9ea0 feat(versions): auto-check for updates on page load
The available-update banner + button now appear automatically when the page
opens, instead of only after clicking 'check'. autoCheck() runs via wire:init
(async, non-render-blocking) and no-ops while an update is in progress so it
never clobbers the running/poll state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:22:53 +02:00
boban 6b5bc6938c fix(versions): persist in-progress update state in Redis (survives reload)
0.9.12 derived the running state from the sentinel file, which the host
watcher consumes BEFORE update.sh runs — so a reload mid-update showed nothing.
Store an 'update in progress' marker (with the base version) in the cache
(Redis in prod) instead: it survives both the reload and the app rebuild. On
load, mount() resumes the 'running' notice + polling, or — if the running
version has already moved past the base — announces completion and clears the
marker. 15-minute TTL backstop so a failed update can't wedge the banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:18:34 +02:00
boban 8b0db1b7ef feat(versions): update completion feedback, instant availability, local time
- Update progress now resolves: a wire:poll detects the rebuilt container
  reporting a newer version than at request time, flips the card back to
  'current' and fires a success toast (survives the restart downtime; times out
  after ~5min instead of spinning; resumes after a reload while the sentinel
  exists). Fixes the 'Update läuft' state that never ended.
- An available update shows on page load straight from the cached check (no
  re-click, no network) instead of only after pressing 'check'.
- Displayed times follow the host timezone instead of UTC: app.timezone is now
  env-driven and install.sh bakes APP_TIMEZONE from the host (DB stays UTC).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:08:54 +02:00
boban deea1a802c fix(versions): never show a 'latest release' older than the installed version
A stale 30-minute update-check cache could render a latest tag below the
installed version right after an update (e.g. 'latest v0.9.9' next to
'installed v0.9.10'), which reads backwards. The passive display now discards a
cached value lower than the installed version until a fresh check resolves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:43:46 +02:00
boban 81a9e4d641 fix(versions): show build commit + branch in production
The Version page read the deployed commit/branch from .git at runtime, which
the prod Docker image does not contain — so it showed 'Build —' and the channel
as the branch. install.sh now bakes the host's git short-SHA and branch into
.env (CLUSEV_BUILD_SHA / CLUSEV_BUILD_BRANCH); the page prefers those and keeps
the live .git read as the dev fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:35:35 +02:00
boban 997b624b0f feat(versions): one-click 'update now' from the dashboard
Add a host-watcher-backed update button on Version & Releases. The container
(no Docker socket) writes an update sentinel; a new root-run systemd unit
(clusev-update.path/.service) runs watch.sh update -> update.sh (git pull +
idempotent install). The sentinel is consumed before running so a persistent
failure can't loop the path unit.

The button only appears once a check finds a newer release, is per-user
throttled (3/10min, auto-expiring) and audited (deploy.update_request); the UI
warns the dashboard is briefly down during the rebuild. install.sh now installs
both the restart and update host units (update unit runs as root for
docker/systemd/apt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:13:34 +02:00
boban fceb0eef77 feat(deploy): one-command update.sh + preserve config on install re-run
Add update.sh (sudo ./update.sh): git pull --ff-only (never discards local
changes), self-update via re-exec if the script itself changed, then hand off
to the idempotent install.sh non-interactively. Sets safe.directory so a
root-run pull works on the clusev-owned tree.

Fix install.sh so a non-interactive re-run (an update) no longer wipes a
configured domain / ACME e-mail: both now default to the existing .env value
when not supplied. First install (empty = bare IP) is unchanged.

README documents 'sudo ./update.sh'; the MOTD shows the update command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:58:50 +02:00
boban 1c3d3d6a01 fix(versions): resolve latest release via the Git host tag API (works in prod)
The update check read the newest release tag from local .git, which the
production Docker image does not contain — so 'check for updates' always
reported 'no release tagged' in prod even when a newer version existed. It now
queries the public, read-only Gitea tags API (anonymous; no token leaves the
panel), keeps local .git only as a dev fallback, and caches the result so a
page render never makes a network call (only the explicit action does).
Channel-aware (stable = vX.Y.Z only; beta also sees prereleases) and degrades
silently when the host is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:31:10 +02:00
boban 9410db1b40 chore(release): v0.9.6
- feat(install): fixed default admin e-mail admin@clusev.local
- fix(js): guard Reverb/Echo init so it can't abort Alpine component
  registration (command-palette auto-opened undismissable on prod first login)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:22:41 +02:00
boban 1e6fdb457e fix(js): never let Reverb/Echo init abort Alpine component registration
The command-palette opened itself full-screen and undismissable right after
first login on production (bare-IP) installs. Root cause: app.js constructed
`window.Echo = new Echo({...})` at module top level. On auth-layout pages the
runtime reverb config (window.__clusev) is absent and the prod bundle bakes no
VITE_REVERB_* fallback (by design — the endpoint is resolved at runtime), so
key/wsHost were undefined and the constructor threw. That throw aborted app.js
BEFORE the alpine:init block registered cmdk / modalTrigger / dualChart. app.js
does not re-run across wire:navigate, so the dashboard inherited the broken
state: Alpine could not resolve x-data="cmdk(...)", stripped x-cloak but could
not evaluate x-show="open", and the palette div fell back to its default flex
display — a dimmed, undismissable overlay. Dev (Vite) hid the bug because it
bakes VITE_REVERB_* from the dev .env, so Echo never threw.

Fix: construct Echo only when a usable runtime config is present, wrap it in
try/catch so a websocket/config failure can never take down the UI, and re-run
the guarded init on livewire:navigated so realtime still connects on a
dashboard reached via SPA navigation from an auth page.

Verified on the real prod build (rebuilt VM image): palette stays closed after
first-login -> dashboard, opens on Ctrl+K and closes on ESC, Echo reaches
connected state, zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:19:31 +02:00
boban b1fb0e766a feat(install): fixed default admin e-mail admin@clusev.local
Default the first-admin login e-mail to a predictable admin@clusev.local
when no --email / CLUSEV_ADMIN_EMAIL is given, instead of a host-derived
admin@<host> address the operator would have to guess. Combined with the
default password 'clusev' (forced change on first login), a fresh install
now has a fully predictable initial login. Override at install time or
change it later under Settings -> Profile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:54:03 +02:00
boban 9215a5b908 chore(release): v0.9.5
- Initial admin now uses the standard default password "clusev" (operator decision) with
  must_change_password forcing a new password on first login; banner/MOTD/README tell the
  operator to change it immediately. Idempotent no-op when an admin already exists.
- Richer host MOTD (CLUSEV wordmark, version, live stack status, login + management hints).
- README: install block now installs git (minimal Debian/Ubuntu lack it) and lists it as a
  prerequisite — found by a full fresh-VM install test (install + idempotent re-run verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:43:04 +02:00
boban b9b4b333e1 feat(motd): richer host login banner + README git prerequisite
Validated install.sh end-to-end on a fresh Debian 13 VM (Docker apt install, clusev
user, image build, stack up, migrate, admin, MOTD) and a fully idempotent re-run.
Two follow-ups from that test:

- MOTD: replace the bare two-liner with an orange CLUSEV wordmark, the version, a LIVE
  stack status ("X/Y Dienste aktiv", green/red via `docker compose ps` at each login),
  the dashboard URL, a login hint (where the one-time credentials come from — no secret),
  and management commands. install.sh substitutes __CLUSEV_URL__, __CLUSEV_VERSION__ and
  __CLUSEV_COMPOSE__; the status lookup degrades to "unbekannt" and never fails a login.
- README: minimal Debian/Ubuntu images ship without `git`, so the documented `git clone`
  failed before reaching the installer. The install block now installs git first and the
  requirements note it. (curl is missing too but the installer self-installs it.)

Admin password strategy confirmed correct as-is: clusev:install generates a random
20-char password with must_change_password=true (forced rotation on first login) — kept
over a guessable default like "clusev", which would be a default-credential vuln and
would nullify the brute-force hardening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:32:25 +02:00
boban 21c087a11f chore(release): v0.9.4
Dashboard "request certificate" button: DNS pre-check + internal SNI handshake that
triggers Caddy's on-demand TLS, with a persisted status, per-user throttle, and
external-TLS/bare-IP gating — all auto-expiring, never locking out the control plane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:41:03 +02:00
boban 1fb6b34fd1 feat(tls): dashboard "request certificate" button (trigger Caddy on-demand TLS)
For the common case where a domain was configured before its DNS pointed here, the
operator can now request the Let's Encrypt cert from System -> Domain & TLS instead of
waiting for the first HTTPS visitor:

- DeploymentService::requestCertificate() does a DNS pre-check (domainResolvesHere via
  dns_get_record vs serverPublicIp) and, only when DNS is NOT a clear mismatch, triggers
  Caddy's on-demand issuance with an internal handshake (probeCertificate:
  `curl --connect-to <domain>:443:caddy:443` — the dial target is pinned to the caddy
  service, the domain only sets SNI/Host, so no SSRF). A clear mismatch returns early and
  never calls ACME (Let's Encrypt rate-limit protection). The outcome is persisted to the
  `tls_cert_status` Setting so the page shows a status without an ACME-triggering probe on
  every load.
- System\Index::requestCertificate() is per-user throttled (cert-request:<id>, 5/10min,
  auto-expiring -> never a control-plane lockout) and audited (tls.cert_request). The
  status line + button render only in caddy mode with an active domain; external mode
  shows a note; bare IP hides it.

Automatic on-demand still works on the first HTTPS handshake regardless — this only adds
explicit control + a status. Tests cover the no-handshake-on-mismatch guard, the
issued/failed/not-applicable states, and the per-user throttle. Full suite 192 green;
Codex clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:21 +02:00
boban 4f1ea8ef0f docs(spec): dashboard "request certificate" button (trigger Caddy on-demand TLS)
DNS pre-check + internal SNI handshake to caddy to trigger on-demand issuance, with a
persisted status (no page-load ACME side effects), per-user throttle, external-TLS-mode
gating, and the control-plane-never-locked constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:24:25 +02:00
boban d6d37583d0 chore(release): v0.9.3
Reliable modal triggers (@persist + spinner/error-toast across all views) and a
brute-force / rate-limiting / auth-DoS hardening pass (per-IP + per-account login
caps, constant-time login, TOTP replay protection, per-account 2FA backstops, a
global /livewire/update throttle, re-auth + SMTP-test throttles, and a fast-fail
SSH connect timeout) — all auto-expiring, never locking out the control plane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:03:44 +02:00
boban f2cbf60c04 fix(security): harden brute-force, rate-limiting and auth-DoS (audit follow-up)
Adversarial audit of the auth surface confirmed several exploitable gaps. Every fix
auto-expires and never permanently locks the control plane (bare-IP recovery host and
the clusev:reset-admin CLI stay open).

Login:
- per-IP (20/min) + per-account (30/15min) buckets on top of the existing email+IP 5/min,
  so a distributed multi-IP brute-force of the single admin account is capped instead of
  scaling linearly with IPs. ip/acct counters decay on their own (a valid login doesn't
  reset a flood's budget).
- constant-time: the unknown-email branch now runs one bcrypt against a fixed DUMMY_HASH
  (evaluated before the `! $user ||` short-circuit), closing the account-enumeration
  timing oracle.

2FA:
- TOTP replay protection — verifyTotp uses verifyKeyNewer with a persisted last-used step
  (two_factor_last_used_step migration), so each 30s code is single-use. Passes 0 (never
  null) as the old step, since null makes verifyKeyNewer return bare `true` not the step.
- challenge + 2FA-proof reset gain an IP-independent per-account backstop, capping the
  distributed TOTP/backup brute-force surface.

Defense-in-depth / DoS:
- global throttle on /livewire/update (180/min per user-id-or-IP) via setUpdateRoute,
  keeping 'web' + the persistent EnsureSecurityOnboarded gate intact.
- reauth throttle (5/min per user) on password change/profile; SMTP test-send capped
  (3/10min per user) so the panel can't relay spam.
- SshClient gets a short, separate connect timeout (~5s) so a dead/tarpit host can't hold
  a worker across several sequential 15s reads (the observed 8.3s /livewire/update DoS).
- profile password policy raised to 12 chars + mixed case + numbers (was 10).

Tests: BruteForceHardeningTest covers TOTP replay, per-email + per-IP login caps,
identical unknown/known rejection, and reauth throttling. Full suite 187 green; Codex
clean; verified in a real browser (login/polls/Livewire updates unaffected by the throttle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:59:45 +02:00
boban 62158eecdc fix(modals): guard wire-elements/modal close path against activeComponent/components desync
Wrap window.LivewireUIModal at alpine:init to null-guard closingModal()
the way the vendor already guards getActiveComponentModalAttribute, so the
click-away path no longer throws "Cannot read properties of undefined
(reading 'name')" when @persist widens the Alpine/Livewire desync window.
In-sync close behavior (incl. a modal vetoing its own close) is unchanged.

Verified: deterministic browser repro flips threw:true -> threw:false; the
real closeModalOnClickAway() entry is clean under forced desync and the modal
still closes; @persist first-open after navigate, spinner, and Escape/Abbrechen
all remain clean (0 console errors). 182 tests pass; Vite build OK; Codex review
approved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:30:24 +02:00
boban e58a08b974 fix(modals): reliable open + spinner/error feedback on every modal trigger
Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.

Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.

CHANGELOG also backfills the prior reset-timing residual fix (2520b87).

Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:07:25 +02:00
boban 2520b87982 fix(security): flatten reset-timing residuals from Codex review
Follow-up hardening on top of 2171895 (which v0.9.2 shipped). That first pass
introduced a cost-12 bcrypt dummy that was *slower* than the real verify path —
a reverse timing oracle plus CPU-DoS amplification. Replace it with work that
mirrors the real path instead of exceeding it, and close the remaining query /
exception asymmetries:

- resetPassword(): resolve 2FA with a uniform lookup — always exactly one
  webauthn-existence query (even for a TOTP user, and for a missing account) so
  its presence/absence can't tell accounts apart. Every path now does one users
  lookup + one webauthn query + one Google2FA HMAC + one locked recovery-code
  transaction (real data when present, throwaway data otherwise).
- Split burnVerificationTime() into dummyTotpVerify() (HMAC) and
  dummyRecoveryLookup() (locked SELECT in a transaction); drop the bcrypt
  Hash::check entirely. A webauthn-only account spends an equivalent throwaway
  HMAC so its crypto cost matches a TOTP account.
- dummyRecoveryLookup() no longer catches DB errors: a DB failure must surface
  identically to the real useRecoveryCode(), else an outage is an error-response
  oracle (generic for unknown, 500 for a real account).
- sendResetLink(): report() the swallowed Throwable so a broken queue/token
  store is detectable instead of silently "succeeding" for every address.

Tests: ForgotPasswordTimingTest now asserts an identical (users,
webauthn_credentials) query fingerprint across TOTP, no-2FA, webauthn-only, and
unknown-email branches, plus queued-mail and rate-limiter parity. Codex re-review
verdict: clean (residual sub-microsecond crypto differences are below the
network/DB noise floor for this self-hosted single-admin threat model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:43:40 +02:00
boban db179ad98a docs(spec): de-Claude repo + git-history rewrite runbook (beta-prep #2)
Deferred runbook for the final beta-prep step: purge CLAUDE.md, rules.md,
handoff.md, docs/session-handoff.md and docs/superpowers from all history,
strip Co-Authored-By trailers, keep the files locally + gitignored, then a
gated force-push. Execute at beta-cut time only, behind explicit confirmation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:30:23 +02:00
boban 3d86c8db8d chore(release): v0.9.2
One-command installer (Docker install, clusev user, DNS check) + themed MOTD,
professional README, and constant-time password-reset (account-enumeration
timing fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:22:03 +02:00
boban 217189598b fix(security): constant-time password-reset (flatten account-enumeration timing)
Queues the reset-link notification so sendResetLink returns in constant time regardless
of account existence; resetPassword does equivalent dummy verify work for unknown/no-2FA
users. Closes the timing side-channels flagged on the forgot-password review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:16:30 +02:00
boban 9035078ac0 docs(readme): professional product README — install (sudo ./install.sh), domain/TLS, recovery, optional 2FA
Replaces the dev-oriented readme: no CLAUDE.md/rules.md references, 2FA is optional
(not forced), documents the overhauled root installer + the clusev user + recovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:15:14 +02:00
boban 4a3c572a89 feat(installer): root one-command bootstrap — apt Docker install, clusev user, DNS check, themed MOTD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:11:51 +02:00
boban c54932b532 docs(spec): installer overhaul (Docker install, clusev user, DNS check) + themed MOTD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:07:52 +02:00
boban 02741539ca chore(release): v0.9.1
UI: system-page redesign, full-width lone firewall/fail2ban panel, mobile-scrollable
settings tabs, instant lazy fail2ban-bans modal, file-manager image preview.
Auth: SMTP-aware forgot-password (15-min link + 2FA fallback), Security-Keys copy fix,
backup codes show-once.
Security hardening: signed single-use confirm-action tokens; airtight backup-code reveal
(transient, snapshot-replay-proof, grant-gated download).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:02:27 +02:00
boban fd3834731e docs: changelog for SMTP-aware forgot-password
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:59:24 +02:00
boban d44f6930bb feat(auth): SMTP-aware forgot-password — 15-min e-mail link + 2FA fallback; recovery note out of the public screen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:53:37 +02:00
boban cb3360b235 docs(spec): SMTP-aware forgot-password (15-min link + 2FA fallback)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:47:53 +02:00
boban 6290cec6ab fix(2fa): bound the backup-code reveal flag with a 10-minute TTL
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>
2026-06-15 18:42:58 +02:00
boban 5ab665321f fix(security): tighten recovery-download grant window (reject stale/non-int/future)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:36:31 +02:00
boban 57a3dd51b6 feat(security): signed confirm-action tokens + airtight backup-code reveal
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>
2026-06-15 18:35:33 +02:00
boban bcf27d25ab fix(settings): horizontally scroll the tab bar on mobile (5 tabs overflowed the viewport)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:34:54 +02:00
boban b91895e103 docs: changelog for backup-codes show-once + system label/domain fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:26:16 +02:00
boban 9a6c09e488 fix(2fa): close two re-view holes in backup-codes show-once
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>
2026-06-15 06:21:45 +02:00
boban dd891cd11e Reveal backup codes only when freshly generated
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>
2026-06-15 06:14:59 +02:00
boban bdccd05cd9 fix(system): shorten TLS-mode label (no wrap) + clean domain placeholder
'Eingebautes TLS' (detail moves to the description line); placeholder is just the
example host (the 'leer = IP' hint is already in the help text).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:11:48 +02:00
boban 9ee3fb520e docs: changelog for post-0.9.0 UI polish (system redesign, security layout, lazy fail2ban modal, key subtitle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:10:32 +02:00
boban 02d74bb147 ui(servers): full-width lone firewall/fail2ban panel with internal 2-col layout
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>
2026-06-15 03:06:59 +02:00
boban 7e1d342844 redesign(system): compose Domain/TLS/Release-Kanal into a 2-column layout
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>
2026-06-15 03:06:49 +02:00
boban 4eeff4916a feat(fail2ban): lazy-load Gesperrte IPs modal behind a skeleton
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>
2026-06-15 03:05:35 +02:00
boban d384a7d918 fix(2fa): Security-Keys subtitle reflects optional/pluggable model (key alone, with TOTP, or both)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:02:28 +02:00
boban 10f594c08d chore(release): v0.9.0
Multi-user admin accounts, database-backed session management (list + per-user/global
revoke), auto-restart sentinel (no docker socket), SMTP configuration, audit-log
retention, plus the Settings split into per-tab components and the UI batch (image
preview, fail2ban bans modal, show-password, system polish).

BREAKING: sessions move from Redis to the database — all existing sessions are
invalidated on update (log in once again).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:51:04 +02:00
boban 79c25fbf83 fix(accounts): make last-user removal atomic (lock + recount) to prevent a zero-user lock-out race
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>
2026-06-14 23:49:21 +02:00
boban 3399a5017a feat(system): auto-restart sentinel — one-click restart via host watcher (no docker socket)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:50 +02:00
boban 73eecd03e0 feat(mail): SMTP configuration in Settings (encrypted password, runtime override, test-send)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:19 +02:00
boban bb1ad53030 feat(audit): configurable retention + scheduled clusev:prune-audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:40:17 +02:00
boban 4ced37ac40 feat(accounts): multi-user admin management — create (temp pw), list, remove, force-logout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:39:34 +02:00
boban 0cdc6c3a7b feat(sessions): database sessions + list/revoke (other devices, per-user, global) with remember-token rotation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:34:00 +02:00
boban 5f6ad27329 refactor(settings): split into per-tab nested components (Profile, Security) + tab hosts for Users/Sessions/Email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:23:25 +02:00
boban 5eb3deff0b docs(spec): 0.9.0 accounts, sessions, auto-restart, SMTP, audit-retention
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:17:41 +02:00
boban cfa442d084 docs: changelog for the UI batch (image preview, fail2ban modal, show-password, system polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:12:54 +02:00
boban 883b6f66fe fix(system): constrain stretched layout, real save icon, de-Caddy copy, SSH-reset hint
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>
2026-06-14 23:10:37 +02:00
boban 77fee98452 feat(auth): show/hide password toggle; email inputs to type=text (class validates)
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>
2026-06-14 23:10:37 +02:00
boban 8cf8c771c8 feat(files): image preview in the file manager; modal polish
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>
2026-06-14 23:10:37 +02:00
boban 8b1bbf401a feat(servers): move fail2ban banned-IP list into a scrollable modal
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>
2026-06-14 23:10:36 +02:00
boban dc885e293b chore(release): v0.8.0
External reverse-proxy TLS mode (Caddy stops ACME + app stops forcing HTTPS;
host check + bare-IP recovery preserved; trusted_proxies via TRUSTED_PROXY_CIDR)
and EnsureSecurityOnboarded as persistent Livewire middleware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:51:30 +02:00
boban 991e17fcee fix(tls): audit TLS-mode changes in applyTlsMode (covers direct Livewire calls, no double-audit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:49:31 +02:00
boban 923e0d4e1c docs: changelog for external-proxy TLS mode + persistent onboarding gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:43:16 +02:00
boban bd6ba03c3c fix(security): register EnsureSecurityOnboarded as persistent Livewire middleware
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>
2026-06-14 22:40:31 +02:00
boban 8ef907d429 feat(tls): System TLS-mode toggle + Caddy trusted_proxies (TRUSTED_PROXY_CIDR) + firewall hint
- Add TLS_MODES const, tlsMode property, confirmTlsMode/applyTlsMode (mirroring channel toggle) to System\Index
- Segmented TLS-mode selector + external-proxy warning hint in system/index.blade.php
- DE + EN lang keys for all new strings (tls_mode_*, change_tls_*, tls_saved_notify)
- Caddyfile: servers { trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} } in global block
- docker-compose.prod.yml caddy service: TRUSTED_PROXY_CIDR env var (safe default 127.0.0.1/32)
- .env.example: documented TRUSTED_PROXY_CIDR knob
- tests/Feature/TlsModeToggleTest.php: 2 new tests (all 7 TLS tests green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:38:21 +02:00
boban 99a183bcc7 feat(tls): external reverse-proxy mode core — no ACME + no forced HTTPS redirect (host check kept)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:31:04 +02:00
boban 19fc6e1f34 docs(spec): external reverse-proxy TLS mode (Zoraxy in front)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:26:20 +02:00
boban cc8e60af5a chore(release): v0.7.0
Optional pluggable 2FA (TOTP and/or WebAuthn, or off) + recovery-codes modal,
live capped journal, command-palette server search, settings tabs in URL,
fail2ban lone-box layout, and SSH-key auto-provision with safe password-login
disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:24:03 +02:00
boban e8843454a6 docs: changelog for SSH-key auto-provision feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:18:14 +02:00
boban 588f5aee2f fix(ssh): best-effort audit + exception-safe switch/verify and modal run(); lock serverId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:16:54 +02:00
boban 8d7f2da8d0 feat(ssh): provision modal + hardening-row action — confirm, run, reveal key once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:09:59 +02:00
boban 6f8f5dbc2b fix(ssh): graceful failure if password-disable throws post-switch; document privateKey-on-failure contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:04:56 +02:00
boban b847b80ab6 feat(ssh): SshKeyProvisioner — generate+install key, verify, switch credential, then disable password (rollback-safe)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:57:45 +02:00
boban 3629f18844 docs(spec): auto-provision SSH key & safely disable password login
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:54:02 +02:00
boban 103453877b docs: changelog for optional pluggable 2FA + journal/cmdk/settings UX fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:49:44 +02:00
boban 4f4b7225f5 fix(2fa): narrow TOTP verify exception handling; guard enrollment against a tampered secret
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:45:12 +02:00
boban 5b1e7e5168 fix(2fa): TwoFactorSetup back to auth layout; exception-safe User::verifyTotp at all call sites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:15 +02:00
boban 61df4497d7 feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path
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>
2026-06-14 21:26:59 +02:00
boban 00337e038c feat(2fa): Settings manages TOTP + keys independently; key can be only factor; empfohlen hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:21:28 +02:00
boban aa43947dcf fix(2fa): memoize challenge pending-user lookup; assert key-only view; tidy hint wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:17:18 +02:00
boban 55d513f241 feat(2fa): challenge adapts to factor — TOTP field gated, key-only leads with the key + backup code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:10:25 +02:00
boban c19a35e43d feat(2fa): generate recovery codes on first factor + open modal; persist Settings tab in URL
- 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>
2026-06-14 21:04:06 +02:00
boban 6a4ba16480 fix(servers): cap a lone firewall/fail2ban panel so it doesn't stretch full width
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:59:58 +02:00
boban a8616809f1 fix(services): cap live journal at 200 rows in a scrollable, auto-sticking box 2026-06-14 20:58:48 +02:00
boban e9a6a6c083 feat(2fa): recovery codes become a modal; drop the dedicated page + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:50:11 +02:00
boban 82d5644767 feat(2fa): onboarding no longer forces 2FA (only password rotation) 2026-06-14 20:44:12 +02:00
boban 9a01bfedf7 feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor) 2026-06-14 20:38:36 +02:00
boban 1273d8290d docs: implementation plan for optional pluggable 2FA
8 TDD tasks covering the approved spec: pluggable factor semantics, optional
onboarding, recovery-codes modal, factor-adaptive login challenge, independent
TOTP/key management in Settings, key-only-safe forgot-password, plus the R12 +
Codex + release sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:20:37 +02:00
boban 44d4046526 fix(services): live journal poll + command-palette server search
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>
2026-06-14 20:13:08 +02:00
boban bdec68f661 docs(spec): optional, pluggable 2FA (TOTP and/or YubiKey) design
2FA becomes optional + pluggable: TOTP, a security key, or both, fully
deactivatable, recommended not forced. hasTwoFactorEnabled = either factor;
onboarding drops the forced-2FA redirect; recovery codes become a modal (drop the
dedicated page/route); challenge adapts to whichever factor(s) exist; last-factor
removal clears codes. WebAuthn E2E stays domain-deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:47:55 +02:00
boban 3bbd6bf096 chore(release): v0.6.1
Sidebar server-switcher syncs with the viewed server (race-free, mount-only).
Documented bfcache/multi-tab edge. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:46:05 +02:00
boban 0b1b5357d2 fix(ui): set active server at mount only (race-free)
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>
2026-06-14 19:43:24 +02:00
boban 7ea789c078 fix(ui): re-sync active server on every details-page request (bfcache-safe)
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>
2026-06-14 19:40:24 +02:00
boban 0088122ff1 revert(ui): drop racy bfcache JS auto-sync; keep race-free mount/targetFor
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>
2026-06-14 19:37:54 +02:00
boban a69551a7eb fix(ui): serialize active-server activations to guarantee ordering
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>
2026-06-14 19:34:19 +02:00
boban f4c24d51a6 fix(ui): cancel stale activate requests + onboarded user in test
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>
2026-06-14 19:32:16 +02:00
boban 6e0ec724ca fix(ui): re-sync active server on bfcache restore of a details page
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>
2026-06-14 19:29:24 +02:00
boban 784566d06b fix(ui): keep the sidebar server-switcher in sync with the viewed server
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>
2026-06-14 19:24:56 +02:00
boban 5ae0b7d135 chore(release): v0.6.0
WebAuthn/YubiKey as an optional login 2nd factor (web-auth/webauthn-lib), gated
on domain+HTTPS. TOTP + backup codes stay the required 2FA. Register/use/remove
keys in Settings + at the challenge; 2FA reset (UI + CLI) clears keys. Real
YubiKey end-to-end is browser-verified on a domain host. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:50:07 +02:00
boban 2b04a18023 fix(webauthn): counter zero only when stored is zero + CLI clears keys
- 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>
2026-06-14 18:47:07 +02:00
boban 44b87d78ba fix(webauthn): tolerate zero-counter authenticators + recheck 2FA on register
- 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>
2026-06-14 18:44:24 +02:00
boban 139c376056 fix(webauthn): hash-indexed credential ids + reset keys on 2FA disable
- 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>
2026-06-14 18:40:24 +02:00
boban 98a39e1257 fix(webauthn): validate the key label before the browser ceremony
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>
2026-06-14 18:35:25 +02:00
boban 5704d6c0f7 fix(webauthn): re-serialize the credential after an assertion
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>
2026-06-14 18:33:20 +02:00
boban fe5c190eb0 feat(webauthn): register/list/remove security keys in settings
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>
2026-06-14 18:29:51 +02:00
boban 7afa50d253 feat(webauthn): use a security key at the login challenge
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>
2026-06-14 18:26:58 +02:00
boban 0cb9b073ce feat(webauthn): registration/assertion options + ceremony verification
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>
2026-06-14 18:24:10 +02:00
boban 54ebb49941 feat(webauthn): WebauthnService::available gate (domain + https)
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>
2026-06-14 18:18:43 +02:00
boban 78b1017844 feat(webauthn): install web-auth/webauthn-lib + credential storage
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>
2026-06-14 18:18:05 +02:00
boban eef79d1758 docs(plan): WebAuthn/YubiKey 2nd-factor implementation plan
TDD plan: install web-auth/webauthn-lib + credential storage, available() gate,
ceremony options/verify (against the installed v5 API), login-challenge use,
settings register/list/remove + JS, and a verification gate. Real key E2E
deferred to a domain+HTTPS host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:16:10 +02:00
boban 27ebc570f4 docs(spec): WebAuthn/YubiKey 2nd-factor design (Phase 2)
Optional security-key 2nd factor via web-auth/webauthn-lib, gated on domain +
HTTPS (rpId = active domain). TOTP + backup codes stay the required 2FA and the
only recovery. Build now with unit/feature tests (mocked ceremony) + R12 gating;
real YubiKey end-to-end deferred to a domain+HTTPS host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:12:32 +02:00
boban 7faa5c7d00 chore(release): v0.5.0
Account recovery: 2FA backup codes (show-once/download/regenerate, usable at the
login challenge), forgot-password via 2FA or backup code (email link gated on a
configured mailer), and a clusev:reset-admin CLI lockout fallback. Resets rotate
remember_token; codes consumed atomically. WebAuthn/YubiKey remains a Phase-2
spec. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:14:47 +02:00
boban 197e6f7e49 fix(auth): show the reset-success status on the login page
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>
2026-06-14 17:10:24 +02:00
boban 2ff2cc34c0 fix(auth): toaster on auth pages + audit email-link resets
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>
2026-06-14 17:07:52 +02:00
boban 3a67aa99ec fix(auth): rotate remember_token on reset + atomic recovery-code use
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>
2026-06-14 17:04:12 +02:00
boban f6a65827fd feat(auth): clusev:reset-admin CLI lockout fallback
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>
2026-06-14 16:57:44 +02:00
boban abaed12b0b feat(auth): email reset-link path (gated on a configured mailer)
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>
2026-06-14 16:57:05 +02:00
boban cb8b906aa8 feat(auth): forgot-password reset via 2FA or backup code
"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>
2026-06-14 16:55:01 +02:00
boban 764e1e5e53 feat(auth): show + download + regenerate 2FA backup codes
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>
2026-06-14 16:52:23 +02:00
boban b2cb94bb32 feat(auth): accept a 2FA backup code at the login challenge
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>
2026-06-14 16:50:36 +02:00
boban f3e595ed45 feat(auth): store 2FA recovery codes (encrypted) on users
Add the two_factor_recovery_codes column (encrypted:array) + User helpers
replaceRecoveryCodes / recoveryCodes / hasRecoveryCodes / useRecoveryCode
(one-time consumption). Hidden from serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:48:21 +02:00
boban 63fb1686a4 docs(plan): account recovery implementation plan
TDD plan: recovery-code storage, challenge backup-code acceptance, recovery-codes
view (show/download/regenerate), forgot-password (2FA-code), email reset link
(gated), clusev:reset-admin CLI, and a verification gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:47:12 +02:00
boban f0a337c8bd docs(spec): account recovery design (forgot-password, 2FA backup codes, CLI)
Phase 1: 2FA recovery codes (encrypted, show-once + downloadable, usable at the
challenge), a forgot-password flow (2FA-code proof primary, email link only when
SMTP is configured), and a clusev:reset-admin CLI lockout fallback. WebAuthn is
deferred to a Phase-2 spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:41:45 +02:00
boban a0ef0e6d9b chore(release): v0.4.6
Bilingual input-field validation messages: added lang/{de,en}/validation.php
(full Laravel 13 key set + localized attribute names), fixing the English-only
fallback. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:47:03 +02:00
boban ecf5bf5fc5 feat(i18n): custom bilingual validation messages (DE + EN)
There was no lang/{de,en}/validation.php, so Laravel fell back to its built-in
English messages for every input-field validation error regardless of locale.
Add the full validation language file in German (source of truth) and English,
plus a localized `attributes` map so :attribute reads naturally (e.g. "SSH-Port",
"E-Mail-Adresse"). Per-form validationAttributes() still override these.

Verified: Validator resolves DE/EN messages + attribute labels; submitting the
empty login form renders "E-Mail-Adresse muss ausgefüllt werden." in German.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:45:02 +02:00
boban 75dcfc11d3 chore(release): v0.4.5
Branding + custom error pages: full favicon/PWA icon set wired into both
layouts, and branded bilingual error pages (403/404/419/429/500/503) replacing
Laravel's defaults with APP_DEBUG pinned off in prod — no raw status text or
stack traces. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:37:25 +02:00
boban 0fcfd31305 fix(errors): never let locale resolution break the error page
The withExceptions render hook called SetLocale::apply(), which resolves the
user via the DB — if the original error was a DB/user-provider failure, that
second access would throw inside the renderer and prevent the custom error page
from rendering (exactly when it's needed). Wrap it so any failure is swallowed
and the page renders in the default locale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:34:44 +02:00
boban d132efd861 fix(i18n): localize error pages thrown before SetLocale middleware
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>
2026-06-14 15:32:20 +02:00
boban c5d3de6e0b feat(branding): favicons + PWA manifest, and custom branded error pages
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>
2026-06-14 15:27:49 +02:00
boban 07f9110f90 chore(release): v0.4.4
Stop iOS zoom-on-focus: an unlayered @media (pointer: coarse) rule forces 16px
on input/textarea/select for touch devices (phone + tablet of any size), keeping
the dense desktop sizing for mouse pointers. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:25:47 +02:00
boban dfe141b406 fix(ui): stop iOS zoom-on-focus by forcing 16px inputs on touch devices
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>
2026-06-14 14:24:05 +02:00
boban cc00aa7d87 chore(release): v0.4.3
Responsive audit (R7) over all views at 375/768: hardened mobile overflow from
long panel domains (break-all/min-w-0) and banned IPv6 addresses in the fail2ban
list. Live pages were already overflow-free; only data-dependent edge cases
fixed. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:07:09 +02:00
boban 36f5f5c514 fix(ui): prevent mobile overflow from long URLs and IPv6 bans
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>
2026-06-14 12:38:47 +02:00
boban e324b14014 docs: record v0.4.2 release in handoff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:03:32 +02:00
boban e706b1e922 chore(release): v0.4.2
Server-Details UX + hardening polish: uniform bordered buttons (danger-soft),
installed-only firewall/fail2ban grid, auto-updates as a neutral preference,
SSH-verified server creation with a "pending" (Initialisierung) status, and an
SSH key-only checklist hint — plus the fail2ban/UFW "nicht installiert"
false-negative fix. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:01:41 +02:00
boban 2b3bab7848 docs: record server-details UX + hardening polish as done
Note the seven fixes (buttons, panel grid, auto-updates reframe, create-verify,
pending status, SSH hint) as completed on the branch, with the verification
trail, and flag the pre-existing stock ExampleTest failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:49:52 +02:00
boban 37efa64b9d fix(ssh): require an exec marker in the connection probe
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>
2026-06-14 10:46:59 +02:00
boban 07f0a0cb00 fix(ssh): accept SSH servers that omit an exit-status packet
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>
2026-06-14 10:43:57 +02:00
boban bc0691a730 fix(servers): keep read-error panels visible + pending header support
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>
2026-06-14 10:41:04 +02:00
boban a5c518822b feat(servers): hint that disabling password login leaves key-only access
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>
2026-06-14 10:30:07 +02:00
boban 21ee81f742 feat(servers): show firewall/fail2ban only when installed, in a grid
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>
2026-06-14 10:28:55 +02:00
boban a9334d11de feat(servers): verify SSH on create + start in "Initialisierung"
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>
2026-06-14 10:26:15 +02:00
boban 2c25303903 feat(ssh): add FleetService::testConnection credential probe
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>
2026-06-14 10:24:42 +02:00
boban ed909edf3e feat(hardening): treat auto-updates as a neutral operator preference
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>
2026-06-14 10:23:28 +02:00
boban 615f440515 feat(ui): add "Initialisierung" (pending) server status
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>
2026-06-14 10:20:32 +02:00
boban e26921f33a feat(ui): uniform bordered button kit; retire ghost variants
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>
2026-06-14 10:18:47 +02:00
boban 50b18aa45f docs(plan): server-details UX + hardening polish implementation plan
Eight-task TDD plan covering the seven fixes: button kit, pending status,
auto-updates reframe, FleetService::testConnection, create-verify, panel grid,
SSH hint, and a final R12+Codex gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:17:28 +02:00
boban a0bc8d41cb docs(spec): server-details UX + hardening polish design
Design for seven reported fixes: unified bordered button kit (retire
ghost/ghost-danger, add danger-soft), firewall+fail2ban panels in an
installed-only responsive grid, auto-updates reframed as a neutral operator
preference (never an insecure verdict), create-server SSH verification with
atomic rollback, a new "Initialisierung" (pending) server status, and an
SSH key-only hint on the checklist. Includes file map, TDD + R12 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:09:17 +02:00
boban 9c3094cc9a docs: mark fail2ban/UFW detection bug resolved (221e49f)
The detection false-negative was already fixed in the working tree; it is now
committed on feat/v1-foundation with a passing unit test, Pint clean, Codex
clean. Update the handoff so the next session sees it as resolved rather than
open. Notes the still-pending items (live acceptance check on 10.10.90.162; no
version bump/tag/push yet) and that the prior task chip is now stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:23:15 +02:00
boban 221e49f24a fix(hardening): detect fail2ban/ufw as installed via binary or active service
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>
2026-06-14 09:22:28 +02:00
boban 2930f16d12 docs: session handoff (state, open fail2ban/UFW detection bug, work recipes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:57:04 +02:00
boban f5f903046d fix(blade): garbled header on every signed-in page + private metrics channel (v0.4.1)
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>
2026-06-14 08:34:07 +02:00
boban a44e29426a feat(domain): change the panel domain from the dashboard (v0.4.0)
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>
2026-06-14 01:38:16 +02:00
boban ec1516184e i18n: complete DE/EN localization (modals, shell, backend) + v0.3.0
- modals: wire 5 hard-coded views + 10 components to lang/{de,en}/modals.php
- shell: command-palette (incl. app.js cmdk(nav,actions) refactor), server-item,
  toaster fallback
- backend: new lang/{de,en}/backend.php; localize Hardening/Firewall/Fail2ban/
  Maintenance services, OsProfile, plus FleetService + CredentialVault errors
- fix: localized page <title> on Dashboard/Servers index+show (->title(), the
  Livewire-3 way; the stray title() methods were never auto-called)
- fix: topbar inline @php() was swallowed by Blade's raw-block precompiler up to a
  later @endphp -> 500 on every authed page; converted to block @php ... @endphp
- versions: localize changelog section labels via a canonical heading map
- translation-quality pass: Unlock, Add credentials, clearer auth/notify copy

Bump 0.2.1 -> 0.3.0; CHANGELOG.

Verified: Pint clean; npm build OK; R12 all routes HTTP 200 + 0 console errors in
BOTH locales (titles/nav/labels switch language); 589 used keys resolve in de+en
with full group parity; Codex review clean; adversarial DE/EN audit (0 high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:27:42 +02:00
boban 5691051ad8 feat(i18n): bilingual foundation (DE/EN) + localize auth/dashboard/servers/fleet/account
Adds user-selectable German (default) + English, per new rule R16.

Foundation:
- config locale=de + fallback=de; User.locale column (+migration); SetLocale middleware
  (user → session → default; SUPPORTED=[de,en]); public /locale/{code} route; DE/EN
  switcher (x-lang-switch) in topbar + auth layout; <html lang> dynamic.
- Translation structure: lang/{de,en}/<group>.php (one group per feature + shared common),
  identical keys across both languages, :placeholder interpolation. Documented as R16 in
  rules.md + CLAUDE.md (R9 reconciled).
- Removed the "Warum Caddy" rationale block from the System page (per feedback).

Localized this pass (views + Livewire components, keys de↔en at parity):
- common, auth, dashboard, servers, services, files, audit, settings, system, versions.
- #[Title] attributes → dynamic title() (attributes can't call __()).

Remaining (next commit): modals components + a few modal views, shell (command palette,
nav-item, server-item, toaster), and backend service messages (app/Services/*, OsProfile).
App is fully functional in German throughout; English covers the groups above.

Boot-verified: /login HTTP 200 in DE + EN, titles + <html lang> localized, locale switch works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:36:16 +02:00
533 changed files with 44137 additions and 3236 deletions

View File

@ -8,7 +8,6 @@
.env
.env.*
!.env.example
.env.gitea
# Deps / build output (reinstalled or rebuilt in the image)
vendor
@ -24,7 +23,10 @@ storage/framework/sessions/*
storage/framework/views/*
database/*.sqlite
# Dev tooling / tests / docs (not needed at runtime)
# Dev tooling / tests / docs (not needed at runtime). NOTE: docs/ is internal-only (export-ignored
# from the public git tree AND named in promote.sh's refuse-to-publish list) — it MUST also be kept
# out of the built image, or `COPY . .` bakes the private docs (with private host/URL refs) into the
# public GHCR image, which the git-tree leak-guard never inspects. Likewise art/ (README assets only).
tests
phpunit.xml
.phpunit.cache
@ -34,6 +36,24 @@ kickoff-prompt.md
rules.md
CLAUDE.md
README.md
docs
art
# Dev-only release bridge — must NOT bake into the PUBLIC prod image (it carries the private
# upstream references). Also export-ignored from the public repo. Dev keeps it via the bind-mount;
# only the built (prod/public) image is stripped. The /release route is release_controls-gated
# (false in prod), so these classes are never loaded at runtime here.
app/Livewire/Release
app/Services/ReleaseBridge.php
app/Services/ReleasePlanner.php
app/Services/PipelineStatus.php
app/Services/PromotionService.php
resources/views/livewire/release
lang/de/release.php
lang/en/release.php
docker/release
scripts/promote.sh
scripts/promote.test.sh
# Compose files (not needed inside the image)
docker-compose.yml

View File

@ -2,7 +2,7 @@ APP_NAME=Clusev
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://10.10.90.136
APP_URL=http://localhost
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
@ -26,7 +26,7 @@ DB_PASSWORD=change-me-strong
# mariadb container only (compose), not read by Laravel:
DB_ROOT_PASSWORD=change-me-strong-root
# ── Cache / Queue / Session via Redis ────────────────────────────────
# ── Cache / Queue via Redis · Session via database ───────────────────
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
@ -34,7 +34,8 @@ REDIS_PASSWORD=null
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
# database = sessions are listable/revocable in Settings (Redis stays for cache/queue)
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -46,7 +47,7 @@ REVERB_APP_ID=changeme
REVERB_APP_KEY=changeme
REVERB_APP_SECRET=changeme
# client-facing (browser connects to the host-exposed port):
REVERB_HOST=10.10.90.136
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
# server bind (inside the container):
@ -64,6 +65,11 @@ FILESYSTEM_DISK=local
# ── Docker Compose knobs (host ports / uid / image) ──────────────────
APP_PORT=80
VITE_PORT=5173
# Dev HMR only: the address the BROWSER uses to reach this box's Vite dev server (the dev VM's LAN IP
# or hostname, e.g. 192.0.2.10). Needed when developing against a REMOTE box — otherwise the CSS/JS
# load from the wrong origin. Leave unset for purely local dev; Vite then infers it from the request
# host. Never used in prod (assets are built). Keep the real address in the untracked .env only.
# VITE_HMR_HOST=
REVERB_HOST_PORT=8080
DB_PORT=3306
# Containers run as this host user (must own the bind-mount). nexxo=1002 on this VM.
@ -80,12 +86,45 @@ IMAGE_TAG=latest
APP_DOMAIN=
APP_SCHEME=http
ACME_EMAIL=
# Deployed commit + branch, shown on the Version page. install.sh fills these from the host's
# .git (the prod image ships none) — leave empty; in dev the page reads .git directly.
CLUSEV_BUILD_SHA=
CLUSEV_BUILD_BRANCH=
# Update source for the in-app version check. install.sh sets this from the clone origin
# (git remote get-url origin). Left UNSET (keep this commented), it falls back to the public repo
# default in config/clusev.php — an empty value here would instead disable the check, so leave the
# key absent rather than blank. Never put a private repo URL in a tracked file — only in the .env.
# CLUSEV_REPOSITORY=https://github.com/clusev/clusev
# Dev-only release controls (dashboard Release page + host git-bridge + live pipeline). Leave unset on
# any non-dev install. Both values below are PRIVATE — never commit them, set only on the dev box:
# CLUSEV_RELEASE_CONTROLS=true # enables the /release page + watcher install
# GIT_STAGING_ACCESS_TOKEN= # GitHub PAT, READ only (Actions:Read + Contents:Read)
# CLUSEV_STAGING_SLUG=owner/private-mirror # the GitHub-private mirror repo (for live CI status)
# Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default.
APP_TIMEZONE=UTC
# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its
# X-Forwarded-Proto. Leave as 127.0.0.1/32 (trust nothing) when Caddy terminates TLS itself.
TRUSTED_PROXY_CIDR=127.0.0.1/32
# Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"):
SITE_ADDRESS=:80
# Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io/<owner>/clusev@sha256:..).
CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_KEY=
# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies
# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned
# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must
# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key.
# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers
# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
TERMINAL_SIDECAR_SECRET=
# Per-install RANDOM honeypot canary values embedded in the served fake .env. install.sh generates
# them; leave empty to DISABLE honeytoken detection (the deception layer still serves plausible bait).
CLUSEV_HONEYPOT_CANARY_APP=
CLUSEV_HONEYPOT_CANARY_DB=
CLUSEV_HONEYPOT_CANARY_API=
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,

32
.gitattributes vendored
View File

@ -7,5 +7,35 @@
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
# Internal-only — never published to the PUBLIC repo. promote.sh copies a release tree with
# `git archive`, which honours export-ignore, so these are dropped automatically. CHANGELOG.md is
# deliberately NOT export-ignored: the public Versions page fetches it from the public repo.
/CLAUDE.md export-ignore
/rules.md export-ignore
/handoff.md export-ignore
/kickoff-prompt.md export-ignore
/docs export-ignore
# Release tooling + the dev-only in-dashboard release bridge — private-CI / dev-box only.
/scripts/promote.sh export-ignore
/scripts/promote.test.sh export-ignore
/tests/scripts/promote-guard.test.sh export-ignore
/app/Livewire/Release export-ignore
/app/Services/ReleaseBridge.php export-ignore
/app/Services/ReleasePlanner.php export-ignore
/app/Services/PipelineStatus.php export-ignore
/app/Services/PromotionService.php export-ignore
/resources/views/livewire/release export-ignore
/lang/de/release.php export-ignore
/lang/en/release.php export-ignore
/docker/release export-ignore
# …and their tests (which reference the dev-only classes above). NOTE: ReleaseChecker.php,
# ReleaseCheckerTest.php and VersionUpdateCheckTest.php are PUBLIC (the update-available check) — do NOT ignore them.
/tests/Feature/PipelineStatusTest.php export-ignore
/tests/Feature/PromotionServiceTest.php export-ignore
/tests/Feature/ReleaseBridgeTest.php export-ignore
/tests/Feature/ReleaseGatingTest.php export-ignore
/tests/Feature/ReleasePageTest.php export-ignore
/tests/Unit/ReleasePlannerTest.php export-ignore

6
.github/actionlint.yaml vendored Normal file
View File

@ -0,0 +1,6 @@
self-hosted-runner:
# Custom self-hosted runner label for the internal staging host (.165).
# The runner is not wired up yet; the deploy-staging job is gated behind
# the STAGING_ENABLED repo variable until it is.
labels:
- clusev-staging

179
.github/workflows/ci-staging.yml vendored Normal file
View File

@ -0,0 +1,179 @@
name: CI + staging
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- run: composer install --no-interaction --prefer-dist --no-progress
- run: cp .env.example .env && php artisan key:generate
- uses: actions/setup-node@v5
with:
node-version: '20'
# Build the Vite manifest BEFORE the tests: full-page tests render @vite layouts, which 500
# without public/build/manifest.json (it is gitignored, so absent on a fresh CI checkout).
- run: npm ci
- run: npm run build
- run: php artisan test
- run: ./vendor/bin/pint --test
- name: shellcheck
run: |
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
docker/wg/clusev-wg.sh docker/restart-sentinel/watch.sh install.sh update.sh scripts/*.sh tests/scripts/*.sh
- name: Script unit tests
run: |
bash tests/scripts/release-images.test.sh
bash tests/scripts/promote-guard.test.sh
# Build the two prod images and push them to the PRIVATE GHCR, tagged by the version. The promote
# job later copies the exact digests to the public GHCR — the image is only public once the release is.
images:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build + push app image (private GHCR, by version tag)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
- name: Build + push terminal image
uses: docker/build-push-action@v6
with:
context: ./docker/terminal
file: docker/terminal/Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
# Smoke: boot the framework inside the freshly built app image. `route:list` loads config +
# every service provider + registers all routes — so a class stripped by export-ignore that is
# still reachable on boot fails HERE, before the image can ever promote. No DB/Redis needed.
- name: Smoke-boot the app image
run: |
set -euo pipefail
docker run --rm \
-e APP_KEY="base64:$(head -c32 /dev/urandom | base64)" \
-e APP_ENV=production -e CLUSEV_RELEASE_CONTROLS=false \
"ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" \
php artisan route:list --json > /dev/null
echo "smoke: framework booted + routes registered"
# Staging (self-hosted runner) pulls the freshly built private image — the first real exercise of
# the pull path. Only runs when a self-hosted runner is wired up (vars.STAGING_ENABLED).
deploy-staging:
needs: images
if: ${{ vars.STAGING_ENABLED == 'true' }}
runs-on: [self-hosted, clusev-staging]
environment: staging
steps:
- name: Update the staging stack (pull the CI-built image)
env:
STAGING_PROJECT_DIR: ${{ vars.STAGING_PROJECT_DIR }}
CLUSEV_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}
CLUSEV_TERMINAL_IMAGE: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}
CLUSEV_PULL: '1'
run: |
cd "${STAGING_PROJECT_DIR:?STAGING_PROJECT_DIR not set}"
sudo -E ./update.sh
# Auto-promote to PUBLIC — only a finalized stable tag (no -rc), and only after test + images are
# green (needs + success()). Copies the exact image digests to the public GHCR and publishes a
# Gitea-free code tree; promote.sh's leak-guard is the last line of defence.
promote:
needs: [test, images]
# Stable = a clean vX.Y.Z with NO pre-release suffix. Excludes -rc AND any accidental pre-release suffix.
if: ${{ success() && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
# Route the tag through an env var instead of interpolating ${{ github.ref_name }} straight into
# run: shells (template-injection hardening / actionlint). The "Enforce a clean stable SemVer tag"
# step below still validates $GITHUB_REF_NAME before any step consumes it.
env:
TAG: ${{ github.ref_name }}
steps:
# Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above
# cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish.
- name: Enforce a clean stable SemVer tag
run: |
[[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $GITHUB_REF_NAME"; exit 1; }
- uses: actions/checkout@v5
with:
fetch-depth: 0
path: src
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
# Read-only: resolve the private image digests. No public side-effect yet.
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${TAG}" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${TAG}" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
# Build + guard the public tree LOCALLY. promote.sh refuses on an already-published tag or a
# leak; nothing is published yet, so a refusal cannot leave an orphaned public image.
- name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
run: |
set -euo pipefail
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
# release-images.lock — consumed by sub-project #2 (install/update pull).
printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \
"$TAG" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \
"${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock
git -C pub add -f release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "$TAG"
# Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release
# ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced,
# content-addressed image tags — re-running the promote republishes them idempotently and retries
# the push; it never advertises a release without an image (which the reverse order would risk).
- name: Publish images to public GHCR
run: |
set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}"
# Atomic: main + tag land together, or neither does.
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/${TAG}"

81
.github/workflows/promote-public.yml vendored Normal file
View File

@ -0,0 +1,81 @@
name: Promote to public (manual fallback)
# Primary promotion is the gated `promote` job in ci-staging.yml (auto on a green stable tag). This
# manual dispatch re-runs the same procedure for a specific tag (e.g. after a promote.sh fix). It
# mirrors the auto job: resolve the exact image digests, build+guard the public code tree, publish
# the images, then push atomically. promote.sh refuses if the tag is already public — yank first.
on:
workflow_dispatch:
inputs:
tag:
description: 'Stable tag to (re-)publish (e.g. v0.11.0)'
required: true
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
# The tag reaches every run-step via this env var (never inline `${{ inputs.tag }}` in a shell —
# that would be a command-injection sink for a hand-typed input).
env:
TAG: ${{ inputs.tag }}
steps:
- name: Enforce a clean stable SemVer tag
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "::error::not a stable vX.Y.Z tag: $TAG"; exit 1; }
- uses: actions/checkout@v5
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
path: src
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Resolve private image digests
id: img
run: |
set -euo pipefail
appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:$TAG" --format '{{.Manifest.Digest}}')
termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:$TAG" --format '{{.Manifest.Digest}}')
{ echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Build the public tree (guarded, no publish)
env:
CLUSEV_PROMOTE_FORBIDDEN: ${{ secrets.PROMOTE_FORBIDDEN }}
APP_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_DIGEST: ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: |
set -euo pipefail
git config --global user.name 'clusev-release'
git config --global user.email 'release@clusev'
chmod +x src/scripts/promote.sh
src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
printf '{ "version": "%s", "app": "%s", "terminal": "%s" }\n' "$TAG" "$APP_DIGEST" "$TERM_DIGEST" > pub/release-images.lock
git -C pub add -f release-images.lock
git -C pub commit -q --amend --no-edit
git -C pub tag -f "$TAG"
# Publish AFTER guards, BEFORE the push (publish-then-advertise): a release ref always has an
# image. A push failure leaves only unreferenced idempotent image tags; re-run to recover.
- name: Publish images to public GHCR
env:
APP_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}
TERM_SRC: ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}
run: |
set -euo pipefail
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:$TAG" "$APP_SRC"
docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:$TAG" "$TERM_SRC"
- name: Push code + tag to public (atomic)
run: git -C pub push --atomic origin HEAD:main "refs/tags/$TAG"

34
.github/workflows/yank.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Yank a public release
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to retract from the public repo (e.g. v0.10.0)'
required: true
permissions:
contents: read
jobs:
yank:
runs-on: ubuntu-latest
environment: production
steps:
- name: Validate the tag format
env:
TAG: ${{ inputs.tag }}
run: |
echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "refusing to yank an unrecognised tag: $TAG" >&2; exit 1; }
- name: Checkout public repo
uses: actions/checkout@v5
with:
repository: ${{ vars.PUBLIC_REPO_SLUG }}
token: ${{ secrets.PUBLIC_REPO_TOKEN }}
fetch-depth: 0
path: pub
- name: Delete the tag on the public repo
env:
TAG: ${{ inputs.tag }}
run: |
git -C pub push origin --delete "refs/tags/${TAG}"

36
.gitignore vendored
View File

@ -6,6 +6,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/framework/active-domain
/storage/pail
/vendor
@ -13,9 +14,13 @@
.env
.env.*
!.env.example
.env.gitea
*.pem
# Self-update trust anchor: the SSH allowed-signers list update.sh checks the pulled HEAD against.
# MUST stay untracked so a pulled/MITM'd tree can never swap the trusted maintainer key (git pull
# leaves ignored files alone). Provisioned out-of-band; see the signature-verification spec.
/.clusev-allowed-signers
.phpunit.result.cache
Homestead.json
Homestead.yaml
@ -33,3 +38,32 @@ yarn-error.log
# local sqlite (we use MariaDB)
/database/*.sqlite*
# runtime host-bridge signal dir (request/result files written by the dashboard + host watchers);
# the dir itself is kept (bind-mount target) but its runtime contents are never committed
/run/*
!/run/.gitkeep
# host-side update transcript (written at the repo root — NOT under ./run — so a compromised
# container cannot symlink-redirect the root updater's tee; see docker/restart-sentinel/watch.sh)
/update.log
/install.log
# transient status temp files: created at the repo root then atomically renamed into ./run (same fs,
# container-inaccessible) so the root write can't be symlink-redirected — normally gone in an instant
/.phase.*
# internal / AI-collaboration docs — kept on the dev box, never committed, mirrored or shipped
/CLAUDE.md
/rules.md
/handoff.md
/kickoff-prompt.md
/docs/
# The release image pin is written ONLY into the promoted public tree by CI (git add -f there);
# a copy must never be committed to the source tree, or a dev install would wrongly switch to pull.
/release-images.lock
# The marketing site + docs (clusev.com / docs.clusev.com) are a SEPARATE project at ~/clusev-site
# with their own compose — they must NEVER land in the panel repo (else anyone could self-host the
# site). Belt-and-suspenders guard in case site files are ever copied in here by mistake.
/clusev-site/

File diff suppressed because it is too large Load Diff

257
CLAUDE.md
View File

@ -1,257 +0,0 @@
# CLAUDE.md — Clusev
Project context for any agent/developer. **Read `rules.md` first — those rules are
non-negotiable.** This file is the fast ramp-up: what we're building, the stack, where things
live, how to run them, and the conventions. Source of truth for decisions: `handoff.md`.
---
## 1. Product
**Clusev** — a self-hosted control panel to administer a **fleet of Linux servers from one
dashboard**, connecting **agentless over SSH**. The operator sees and steers the whole fleet
(metrics, services, files, audit) in one security-first UI (2FA, full audit log).
- **Backend reality:** Laravel is the **control-plane** (UI + API + provisioning). It does **not**
reimplement SSH/daemons — it orchestrates real servers over SSH via **phpseclib** (exec + SFTP).
- **Audience:** developers, self-hosters, sysadmins.
- **Differentiator:** multi-server fleet management with a modern, polished UI. **Multi-server is
free and never paywalled.** Paid = Team/Enterprise (SSO/LDAP, RBAC, audit export, alerting,
backups) + optional managed control-plane. **License:** AGPL core + commercial Pro modules —
architect open-core from day one (clean core, Pro as separate modules/flags).
### v1 scope (build this first — do NOT boil the ocean)
- **Foundation (once):** auth + **2FA**, **audit log**, encrypted **SSH-credential vault**,
**SSH layer** (exec + SFTP via phpseclib), **Reverb** realtime channel, **queue** workers,
**multi-server switcher**.
- **Features:** (1) **Dashboard / live metrics**, (2) **systemd services** (list + start/stop/
restart + logs), (3) **SFTP file manager**, plus the **Server-Details** page (identity,
resource gauges, specs, volumes, interfaces, security hardening, SSH keys).
### Deferred (keep OUT of v1)
- **Web terminal** (xterm.js + ws↔SSH sidecar — PHP can't hold an interactive PTY).
- **Package management** (apt/dnf), firewall / users / cron, app store, push-metrics agent.
---
## 2. Tech stack (hard requirements)
| Layer | Choice |
|---|---|
| Framework | **Laravel 13** |
| UI / interactivity | **Livewire v3** — class-based, **NOT Volt** |
| CSS | **Tailwind v4** (CSS-first, `@theme` in `app.css`, **no `tailwind.config.js`**) |
| Build | **Vite** |
| Modals | **wire-elements/modal** |
| Realtime | **Laravel Reverb** + Echo (live metrics, broadcasts) |
| Queue / cache | **Redis** |
| DB | **MariaDB** *(confirmed)* |
| SSH | **phpseclib** (exec + SFTP) |
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) |
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
**Composer packages:** `livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb`.
**NPM (Tailwind v4):** `tailwindcss @tailwindcss/vite` (+ Echo + a charts lib).
---
## 3. Architecture notes
- **Pages = full-page class-based Livewire components** mapped directly in `routes/web.php`
(R1/R2). No page controllers.
- **SSH layer** (`app/Support/Ssh/`): `SshClient` (exec), `Sftp`, `CredentialVault`
(encrypted credentials). Domain services in `app/Services/` (`FleetService`, `MetricsPoller`,
`Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`.
- **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over
**Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates
the chart island. **Mock data first, real SSH after.**
- **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one.
---
## 4. Folder map (§5 — follow exactly, R6)
```
app/
Livewire/ # class-based components (full-page = routes, + nested)
Dashboard.php
Servers/{Index.php, Show.php} # Show = Server-Details page
Services/Index.php
Files/Index.php
Audit/Index.php
Auth/{Login.php, TwoFactor.php}
Modals/ # wire-elements/modal components (ConfirmDelete, ServerForm, …)
Concerns/ # shared traits (e.g. WithFleetContext)
Models/ # Server, AuditEvent, User, …
Support/Ssh/ # phpseclib wrappers: SshClient, Sftp, CredentialVault
Services/ # FleetService, MetricsPoller, Provisioner
Events/ # broadcast events (MetricsTicked, …)
Jobs/ # queued ops
resources/
views/
livewire/ # mirrors app/Livewire (kebab-case)
dashboard.blade.php
servers/{index,show}.blade.php
services/index.blade.php
files/index.blade.php
audit/index.blade.php
modals/…
components/ # Blade UI: panel, kpi, status-pill, status-dot, badge,
… # sidebar, topbar, server-item, nav-item, icon, ring
layouts/app.blade.php
css/app.css # @import "tailwindcss"; + @theme { tokens } + base layer
js/app.js # Echo/Reverb bootstrap, Alpine islands (charts; xterm later)
routes/web.php # full-page Livewire routes
config/livewire.php # class_namespace=App\Livewire, view_path=views/livewire
docker/ # Dockerfile bits, php-fpm/nginx config, entrypoint
docker-compose.yml # DEV (build, bind-mount, Vite)
docker-compose.prod.yml # PROD (image from GHCR, no mount)
.dockerignore
handoff.md rules.md CLAUDE.md
```
---
## 5. Design system → Tailwind v4 `@theme`
Direction: **"Tactical Terminal"** — dark graphite, **signal-orange** brand (softened on
selection/active tints), **cyan** counter-accent, ops status triad, **mono for every
number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
- **Surfaces:** `void, base, surface, raised, inset``bg-surface`, `bg-raised`, …
- **Brand:** `accent, accent-bright, accent-deep, accent-text``text-accent`, `bg-accent/10`
(use **opacity utilities** for tints, e.g. `bg-accent/10`, `border-accent/25` — no `--accent-dim`).
- **Counter-accent:** `cyan, cyan-bright`.
- **Status:** `online (#35D07F)`, `warning (#E8B931)`, `offline (#FF5247)``text-online`, …
- **Text ramp:** `ink, ink-2, ink-3, ink-4``text-ink`, `text-ink-2`, …
- **Borders (hairline):** `line, line-soft, line-strong``border-line`, …
- **Fonts:** `--font-display` (Chakra Petch), `--font-sans` (Space Grotesk), `--font-mono`
(JetBrains Mono) → `font-display`, `font-mono`.
- **Radii:** `--radius-xs/sm/md/lg``rounded-sm`, `rounded-lg`.
> Full token values are in `handoff.md` §6 — port them verbatim into `app.css`. Selection
> (`::selection`) and active nav/server tints use `bg-accent/25` and `bg-accent/10` + `border-accent/25`
> (keep subtle). **`../bastion` mockup is NOT present on this machine** → rebuild from §6 tokens +
> the §11 component list; pull Lucide SVG paths directly from Lucide.
**Blade components to build:** `x-panel`, `x-kpi`, `x-status-pill`, `x-status-dot`, `x-badge`,
`x-icon` (Lucide), `x-ring`, `x-server-item`, `x-nav-item`, plus `sidebar` + `topbar` partials.
---
## 6. Conventions
- **UI copy: German**, terse/operational, **no emoji** (status = color/dots/pills). Native
technical tokens stay as-is (`nginx.service`, `chmod 600`, `SSH`, `2FA`). (R9)
- **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3)
- **Inline styles:** forbidden except a progress bar's `width`. (R4)
- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show`
(kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`.
- **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2)
- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5)
- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer
PK (`getRouteKeyName(): 'uuid'`). (R11)
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
- **Fonts self-hosted only**`.woff2` in `resources/fonts/` (Vite-bundled, relative
`url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `<link>` or
`@import`. (R14)
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
- **Docs language:** these meta-docs are in English; **UI strings are German**.
---
## 7. Commands (everything in containers — R8)
> Dev stack services: `app` (php-fpm + nginx + **vite** via supervisor — ONE container),
> `reverb`, `queue`, `mariadb`, `redis`. App on **:80**, Vite HMR on **:5173**
> (`hmr.host=10.10.90.136`). Vite is **not** a separate service. Containers run as `HOST_UID`
> (1002 on this VM); run write-tooling as
> `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app …`.
```bash
# ── Stack lifecycle (DEV) ────────────────────────────────────────────────
docker compose up -d # start the dev stack
docker compose logs -f app # tail app logs
docker compose down # stop
# ── Run tooling INSIDE the container ─────────────────────────────────────
docker compose exec app php artisan migrate
docker compose exec app php artisan make:livewire Servers/Show # class + view (never make:volt)
docker compose exec app composer require some/package
docker compose exec app php artisan tinker
# ── One-time bootstrap (host has no PHP/Composer/Node) ───────────────────
# run in a throwaway container or the app image:
# composer create-project laravel/laravel .
# composer require livewire/livewire wire-elements/modal phpseclib/phpseclib laravel/reverb
# npm install -D tailwindcss @tailwindcss/vite
# php artisan livewire:publish --config # class-based, view_path set
# ── Realtime / queue (run as their own services; manual run if needed) ───
docker compose exec app php artisan reverb:start
docker compose exec app php artisan queue:work
# ── Prod deploy (plain Docker Compose — no Portainer) ────────────────────
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
```
---
## 8. Infrastructure & deploy (§8)
- **Target VM:** Debian 13, **`10.10.90.136`**, **only Docker** + a sudo user. Everything in
containers. **No Portainer, no external orchestrator.** Dev happens on this same VM (prod-parity).
- **`docker-compose.yml` (DEV):** `build:`, **bind-mounts** source. The `app` container runs
php-fpm + nginx + Vite (HMR) via supervisor (one unit). Ports/UID are **env-driven**
(`APP_PORT`, `VITE_PORT`, `REVERB_HOST_PORT`, `DB_PORT`, `HOST_UID`/`HOST_GID` in `.env`) —
nothing hardcoded. App on **:80**, Vite remote HMR (`server.host=0.0.0.0`,
`hmr.host=10.10.90.136`), MariaDB only on `127.0.0.1:3306`.
- **`docker-compose.prod.yml` (PROD):** `image: ghcr.io/OWNER/clusev:<tag>` *(GHCR namespace is a
**placeholder `OWNER`** — set via `.env` before the first prod push)*, **no bind-mount**, assets
baked into the image, no Vite. Reverse proxy + TLS (Caddy/Traefik) is just another service.
- **Deploy:** wrap pull+up+migrate in `deploy.sh` (or a CI job that SSHes in). Image flow:
push → CI builds + pushes to **GHCR**`deploy.sh` pulls onto the VM. Bootstrap before CI:
build on the VM. Use `docker buildx` (multi-arch) only if any dev happens on arm64; prod is amd64.
---
## 9. Repo & workflow
- **Project root = `/home/nexxo/clusev`** (its own git repo). The parent `$HOME` holds only
`.env.gitea` + home dotfiles. Host user **nexxo = uid 1002** (`wp-test`=1000, `testwp`=1001
pre-existed) → `HOST_UID/HOST_GID=1002` in `.env`.
- **Remote:** Gitea `git.bave.dev/boban/clusev.git`. Credentials live in **`.env.gitea`**
(untracked) — the `GIT_ACCESS_TOKEN` must later move into the app `.env`.
- **Work on a feature branch, not `main`.** Commit in sensible steps.
- **Secret hygiene:** `.env*` (incl. `.env.gitea`) + keys are gitignored; never commit/echo the
Gitea token (read it only at push time). See `rules.md` → Appendix.
- **Verify before "done" (R12):** load **every touched page in a real browser** (headless ok) →
**HTTP 200** (no 500) + **zero console/network errors**; for lazy `wire:init` pages check the
**loaded** state, not the skeleton. A green `Livewire::test` is **not** enough. Test the 3 breakpoints.
---
## 10. Before you code — checklist
1. **Read `rules.md`.** R1R11 are non-negotiable; on any conflict, **stop and ask**.
2. New page? → full-page **class-based** Livewire component + route in `web.php` (R1/R2). Never Volt.
3. Need a color/spacing? → an **`@theme` token utility** (R3). No raw hex, no inline style except
progress `width` (R4).
4. Destructive/confirm? → **wire-elements/modal** (R5).
5. Place files per the **§4 folder map** (R6). Class ↔ mirrored kebab view.
6. Build **responsive**: 375 / 768 / 1280; ≥44px touch targets (R7).
7. Run all tooling **inside the container** (R8).
8. UI copy **German, no emoji**; status via color/dots/pills (R9).
9. **Reuse** the token set + Blade component kit (R10).
10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console
errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough;
build + 3 breakpoints; then `git status` for stray secrets, commit on the feature branch.
11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and
no security issues**. Fix + re-run until clean. The task is not done until Codex passes.

242
README.md
View File

@ -1,83 +1,223 @@
# Clusev
<div align="center">
Self-hosted control panel to administer a **fleet of Linux servers from one dashboard**,
agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers
with **phpseclib** (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted
SSH-credential vault, and a full audit log. Multi-server is free and never paywalled.
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details,
> auth + 2FA. UI copy is German; meta-docs are English.
<br>
**Stack:** Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3.
Everything runs in Docker. See `CLAUDE.md` for architecture and `rules.md` for the hard conventions.
**One dashboard for your whole fleet. Agentless over SSH. Self-hosted, security-first, open core.**
[![License](https://img.shields.io/badge/license-AGPL--3.0-FF6B2C?style=flat-square&labelColor=0F1318)](#license)
[![Laravel](https://img.shields.io/badge/Laravel-13-9DAAB6?style=flat-square&labelColor=0F1318)](https://laravel.com)
[![Livewire](https://img.shields.io/badge/Livewire-3-9DAAB6?style=flat-square&labelColor=0F1318)](https://livewire.laravel.com)
[![Docker](https://img.shields.io/badge/Docker-ready-43C1D8?style=flat-square&labelColor=0F1318)](#quick-start)
[![Self-hosted](https://img.shields.io/badge/self--hosted-Debian%20%2F%20Ubuntu-35D07F?style=flat-square&labelColor=0F1318)](#requirements)
[**Features**](#features) · [**Architecture**](#architecture) · [**Quick start**](#quick-start) · [**Domain &amp; TLS**](#access-domain--tls) · [**Security**](#security--recovery) · [**License**](#license)
</div>
---
## Development
## What is Clusev
The host needs only Docker (no PHP/Composer/Node). The dev `app` container runs php-fpm + nginx +
Vite via supervisor; `reverb`, `queue`, `mariadb`, `redis` are their own services.
**Clusev** is the control plane for a fleet of Linux servers — a modern, security-first web UI that
administers your machines by talking to them **directly over SSH** (exec + SFTP via
[phpseclib](https://phpseclib.com)). It installs **nothing** on the target servers and never
reimplements their daemons; it orchestrates the real ones.
```bash
cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build # app on :80, Vite HMR on :5173
docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate
docker compose exec app php artisan migrate --seed
```
> **Multi-server management is free and never paywalled.** Optional Pro modules (SSO/LDAP, RBAC,
> audit export, alerting) are separate — the core stays open.
Run any tooling inside the container, e.g.
`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt).
<div align="center">
| | | |
|:--|:--|:--|
| **Agentless** | **Security-first** | **Self-hosted** |
| Pure SSH + SFTP. No daemon, no package, no push-agent on your servers. | 2FA, encrypted credential vault, tamper-evident audit log, per-device sessions. | One Docker stack on one VM. Your servers, your data, your keys. |
</div>
---
## Production install
## Features
One host, only Docker + a sudo user. **Caddy** is the single host-exposed service: auto-TLS when a
domain is set, plain HTTP on the bare IP otherwise. `install.sh` is idempotent — it generates
secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin
with a **one-time random password printed only on the terminal**.
| Capability | What you get |
|:--|:--|
| **Dashboard &amp; live metrics** | CPU, memory, load and disk per server, streamed in real time over WebSockets. |
| **systemd services** | List, start / stop / restart, and tail the live journal — per service, per server. |
| **SFTP file manager** | Browse the remote filesystem, edit text files in place, preview images. |
| **Web terminal** | A full SSH terminal in the browser — one per server, and one for the Clusev host itself. Real PTY with tab-completion; the connection's host key is pinned against MITM. |
| **Server details &amp; hardening** | Resource gauges, volumes, interfaces and SSH keys; UFW / firewalld rules and fail2ban status with one-click controls; a guided _"generate an SSH key and disable password login, safely"_ flow. |
| **Security** | Pluggable 2FA (TOTP and/or hardware keys, or off), one-time backup codes, an encrypted SSH-credential vault, and a complete tamper-evident audit log. |
| **Multiple administrators** | Add further admin accounts — every action is attributed in the audit log; view and revoke active sessions per device, per user, or globally. |
| **Domain, TLS &amp; e-mail** | Run on a bare IP over HTTP, set a domain for automatic Let's Encrypt HTTPS, or sit behind your own reverse proxy. In-panel SMTP for password-reset mail. |
| **WireGuard (built-in)** | Stand up a WireGuard tunnel from the dashboard, manage peers and live status, and optionally gate the panel — and even SSH — to the tunnel only. |
The interface is **German by default** (English available). Status is shown the operational way —
colour, dots and pills — never emoji.
---
## Architecture
<div align="center">
<img src="art/clusev-architecture.svg" alt="Operator → Clusev control plane → your fleet" width="100%">
</div>
Laravel is the **control plane**, not a re-implementation of your servers. The browser talks to it
over HTTPS and a Reverb WebSocket for live telemetry; it reaches each server over **SSH exec + SFTP**
using credentials sealed in an encrypted vault that never leaves the control plane. Everything runs as
one Docker stack — `app` (php-fpm + nginx + Vite), `reverb`, `queue`, `scheduler`, `mariadb`,
`redis` — on a single VM.
| Layer | Technology |
|:--|:--|
| Framework | **Laravel 13** |
| UI / interactivity | **Livewire 3** (class-based) |
| Styling | **Tailwind v4** (CSS-first, `@theme`) |
| Realtime | **Laravel Reverb** + Echo |
| Queue / cache | **Redis** |
| Database | **MariaDB** |
| SSH transport | **phpseclib** (exec + SFTP) |
| Reverse proxy / TLS | **Caddy** (automatic Let's Encrypt) |
| Packaging | **Docker** + Docker Compose |
---
## Quick start
```bash
git clone https://git.bave.dev/boban/clusev.git && cd clusev
./install.sh
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
git clone https://github.com/clusev/clusev.git
cd clusev
sudo ./install.sh
```
Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment):
The installer is **idempotent** (safe to re-run) and, in one pass:
```
Domain (empty = access by IP over HTTP): clusev.example.com
Admin e-mail (login + Let's Encrypt): admin@example.com
```
1. Installs **Docker** (Debian/Ubuntu, from Docker's official repository) if it isn't already present.
2. Creates a dedicated **`clusev`** system user — in the `docker` group, owning and running the stack
— with a random password.
3. Installs over the server's **IP address (plain HTTP)** — no domain is asked for. Prompts only for
the **HTTP port** (default 80) and an **admin e-mail** (default **`admin@clusev.local`**), or reads
`CLUSEV_ADMIN_EMAIL` / an optional `CLUSEV_DOMAIN` from the environment for an unattended install. A
domain with automatic HTTPS is set later in the dashboard — see [Access, domain &amp; TLS](#access-domain--tls).
4. Generates all secrets (once — never regenerated on re-run), builds the image, starts the stack,
runs the migrations, and creates the first administrator with a **random initial password** printed
in the closing summary.
5. Installs a themed host login banner (MOTD) showing the dashboard address and live stack status.
The closing banner shows the URL + the one-time admin password. On first login Clusev **forces** a
password change and 2FA enrolment before the panel unlocks.
When it finishes, the terminal prints a single summary: the **dashboard URL**, the **admin login**
(your e-mail — `admin@clusev.local` if you left it empty — plus a **random initial password**), and
the **`clusev` host user** + its own random password. Both passwords are shown **only once** — note
them down.
### One knob: `APP_DOMAIN`
### First login
| `APP_DOMAIN` | proxy | URL | Reverb |
|---|---|---|---|
| *(empty)* | Caddy serves plain HTTP on `APP_PORT` | `http://<ip>` | `ws://<ip>/app/*` |
| `clusev.example.com` | Caddy gets a Let's Encrypt cert, forces HTTPS | `https://…` | `wss://…/app/*` |
Log in with your admin e-mail and the **initial password from the installer's summary**. Setting your
own password is **recommended** — a banner keeps reminding you until you do — but no longer forced, so a
missed reminder can never lock you out of your own panel. You can change the login e-mail later under
**Settings → Profile**. 2FA is **optional but recommended** — enable TOTP and/or a security key from
**Settings → Security** whenever you like. A short **guided tour** highlights the key areas on first
login (replay it any time from Settings).
`SITE_ADDRESS`, `APP_URL`, `REVERB_*` are **derived** from it by the installer — nothing hardcoded.
Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs
publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead.
---
### Manual deploy (for operators who don't `curl | bash`)
## Requirements
**The Clusev panel host** — the one VM that runs the dashboard:
- **OS:** Debian 12/13 or Ubuntu 22.04/24.04, with **root** access.
- **CPU / RAM:** 1 vCPU and **1 GB RAM** minimum (**2 GB recommended**) — the whole Docker stack
(app, MariaDB, Redis, Reverb, queue) runs here.
- **Disk:** ~**5 GB** free minimum (**10 GB+** recommended — image and database grow over time).
- **Network:** a **public IP**; optionally a domain whose DNS points at the server (for automatic
HTTPS — Let's Encrypt needs ports **80/443** reachable).
- **Tools:** only **git** to fetch the repo — the installer sets up Docker and everything else.
**The servers you manage** need **nothing installed** — just **SSH** reachable from the panel and a
standard Linux `/proc`. Full control (service management + hardening) additionally needs **systemd**
and one of `apt` / `dnf` / `zypper`. See [**Supported operating systems**](#supported-operating-systems).
---
## Supported operating systems
Clusev is **agentless** — it manages whatever it can reach over SSH. How much works depends on the
target's init system and package manager:
| OS family | Metrics | systemd services | Hardening<br>(SSH · fail2ban · firewall · auto-updates) |
|:--|:-:|:-:|:-:|
| **Debian · Ubuntu** (and derivatives) | ✅ | ✅ | ✅ |
| **RHEL · Fedora · Rocky · AlmaLinux · CentOS Stream · Amazon Linux** | ✅ | ✅ | ✅ |
| **openSUSE · SLES** | ✅ | ✅ | ✅ |
| **Arch** | ✅ | ✅ | ⚠️ not yet (`pacman` not wired) |
| **Alpine** (OpenRC) | ✅ | ❌ no systemd | ❌ |
| **Other / unidentified Linux** | ✅ if `/proc` present | ❌ | ❌ |
**Metrics** (CPU, memory, disk, load) read from `/proc` and work on essentially any Linux. **Service
control** needs **systemd**. **Hardening** uses the host's firewall (`ufw` / `firewalld`) and package
manager (`apt` / `dnf` / `zypper`); where those aren't present, the action is shown as unavailable
rather than failing silently.
---
## Access, domain &amp; TLS
- **Bare IP (no domain):** served over plain HTTP at `http://<server-ip>`. This address always stays
reachable as a recovery path, even after a domain is configured.
- **With a domain:** set it in the dashboard under **System → Domain &amp; TLS** at any time (or preset
`CLUSEV_DOMAIN` at install for an unattended setup). The panel
obtains and renews a Let's Encrypt certificate automatically and serves HTTPS — just point DNS at the
server. (Let's Encrypt needs publicly reachable ports 80/443.)
- **Behind your own reverse proxy** (one that already terminates TLS): switch _TLS-Terminierung_ to
**Externer Reverse-Proxy** in System → Domain &amp; TLS. The panel then serves HTTP only and trusts
the proxy's forwarded scheme — set `TRUSTED_PROXY_CIDR` to the proxy's address and firewall the HTTP
port so only the proxy can reach it.
Domain/TLS changes apply on a stack restart — use the **"Jetzt neu starten"** button in System (no
terminal needed; a small, scoped host service performs the restart).
---
## Updating
```bash
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
```
> In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified,
> 2FA-gated, with backup + rollback) are designed in `docs/install-update-design.md` and land in
> v1.x — they are intentionally **not** shipped yet.
`sudo clusev update` fast-forwards the repo (it never discards local changes), re-runs the idempotent
installer non-interactively, and updates itself if the script changed. Secrets and the configured
domain / e-mail are preserved. (The older two-step `git pull && sudo ./install.sh` still works.)
---
## Security &amp; recovery
The **forgot-password** screen offers self-service recovery: an **e-mail reset link** (valid 15
minutes) when SMTP is configured, or an inline **2FA-proof reset** (e-mail + TOTP or a backup code +
new password) as a fallback.
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
```bash
clusev reset-admin
```
This clears the second factor so you can set a new password on the next login. The bare-IP
`http://<server-ip>` address is also always available if a domain becomes unreachable. (This command
is documented in-panel under Settings → Security and is deliberately not shown on the public
forgot-password screen.)
---
## License
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules (SSO/LDAP,
RBAC, audit export, alerting) are separate.
<div align="center">
**Project home — [github.com/clusev/clusev](https://github.com/clusev/clusev)**
</div>

View File

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\ReleaseChecker;
use Illuminate\Console\Command;
/**
* Refresh the cached latest-release tag so the global sidebar update badge stays accurate without
* the operator first opening the Versions page. Anonymous + read-only against the public Git host.
*/
class CheckUpdate extends Command
{
protected $signature = 'clusev:check-update';
protected $description = 'Refresh the cached latest-release tag (keeps the sidebar update badge warm).';
public function handle(ReleaseChecker $checker): int
{
$tag = $checker->refresh();
$this->info($tag !== null ? "latest release: v{$tag}" : 'remote unreachable — keeping the previous cache');
return self::SUCCESS;
}
}

View File

@ -9,18 +9,27 @@ use Illuminate\Support\Str;
/**
* Idempotent first-run bootstrap (called by install.sh, phase 7).
*
* Creates the initial admin with a one-time random password that is printed
* exactly once on stdout never written to .env, a file, or the database in
* plaintext. Re-running once an admin exists is a hard no-op (INSTALL_LOCK
* idiom): no default credential, no "first-visitor-wins" claim window.
* Creates the initial admin with a RANDOM initial password (printed once by install.sh from the
* CLUSEV_ADMIN_PASSWORD line below) rather than a guessable literal rotation is optional now, so a
* well-known default like "clusev" would be a standing takeover credential. `must_change_password` is
* still set (the banner nudges a rotation), but even if kept, the generated secret is not guessable.
* Re-running once an admin exists is a hard no-op.
*/
class Install extends Command
{
/**
* Standard default admin e-mail (login + Let's-Encrypt contact) when none is given. A fixed,
* predictable value so a fresh, unattended install always logs in at admin@clusev.local
* not a host-derived address the operator would have to guess. Override with --email /
* CLUSEV_ADMIN_EMAIL, or change it later under Settings Profile.
*/
private const DEFAULT_EMAIL = 'admin@clusev.local';
protected $signature = "clusev:install
{--email= : Admin-Login + Let's-Encrypt-E-Mail}
{--name=Administrator : Anzeigename des Admins}";
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Einmal-Zufallspasswort an';
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit zufälligem Initialpasswort an (Wechsel empfohlen, nicht erzwungen)';
public function handle(): int
{
@ -30,8 +39,7 @@ class Install extends Command
return self::SUCCESS;
}
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost';
$email = (string) ($this->option('email') ?: "admin@{$host}");
$email = (string) ($this->option('email') ?: self::DEFAULT_EMAIL);
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Ungueltige Admin-E-Mail: {$email}");
@ -39,22 +47,24 @@ class Install extends Command
return self::FAILURE;
}
$password = Str::password(20);
// Random, non-guessable initial password (printed once below). Satisfies any policy.
$password = Str::password(16);
$user = User::create([
'name' => (string) ($this->option('name') ?: 'Administrator'),
'email' => $email,
'password' => $password, // 'hashed' cast hashes on save
'must_change_password' => true, // forces rotate + 2FA on first login
'must_change_password' => true, // nudges a rotation on first login (optional)
'role' => 'admin', // the first/root account is a full administrator
]);
// One-time password marker — the ONLY place the plaintext appears.
// install.sh greps these two lines for the closing banner; never stored.
// install.sh greps these two lines for the closing banner — this is the ONLY time the
// generated password is shown, so the operator must note it now.
$this->newLine();
$this->line('CLUSEV_ADMIN_EMAIL='.$user->email);
$this->line('CLUSEV_ADMIN_PASSWORD='.$password);
$this->newLine();
$this->info('Admin angelegt. Passwort erscheint nur jetzt und wird nicht gespeichert.');
$this->info('Admin angelegt. Generiertes Passwort jetzt notieren — es wird nur einmal angezeigt.');
return self::SUCCESS;
}

View File

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Events\MetricsTicked;
use App\Models\Server;
use App\Services\AlertEvaluator;
use App\Services\FleetService;
use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\SshClient;
@ -28,7 +29,7 @@ class PollMetrics extends Command
protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast';
public function handle(FleetService $fleet, CredentialVault $vault): int
public function handle(FleetService $fleet, CredentialVault $vault, AlertEvaluator $alerts): int
{
$interval = max(2, (int) $this->option('interval'));
@ -36,10 +37,22 @@ class PollMetrics extends Command
$clients = [];
do {
$servers = Server::has('credential')->get();
// Only servers with a PRESENT, non-disabled credential. A credential disabled
// between ticks drops the server here, so its cached connection is no longer polled
// (the vault refuses a fresh connect, but a reused long-lived session would not
// re-check disabled_at). Prune any cached client whose server just left the set so
// the revoked server's open session is closed promptly, not left idle until exit.
$servers = Server::withActiveCredential()->get();
$activeIds = $servers->pluck('id')->all();
foreach (array_keys($clients) as $id) {
if (! in_array($id, $activeIds, true)) {
$clients[$id]->disconnect();
unset($clients[$id]);
}
}
if ($servers->isEmpty()) {
$this->warn('Keine Server mit Credential — nichts zu pollen.');
$this->warn('Keine Server mit aktivem Credential — nichts zu pollen.');
}
foreach ($servers as $server) {
@ -47,8 +60,9 @@ class PollMetrics extends Command
$clients[$server->id] ??= (new SshClient($vault, timeout: 12))->connect($server);
$m = $fleet->readMetrics($clients[$server->id]);
$fleet->applyMetrics($server, $m);
$fleet->applyMetrics($server, $m); // forceFills the fresh status onto $server
broadcast(new MetricsTicked($m['cpu'], $m['mem'], $server->name));
$this->evaluateAlerts($alerts, $server, $m, $server->status);
$this->line("ok {$server->name} cpu={$m['cpu']} mem={$m['mem']} disk={$m['disk']} load={$m['load']}");
} catch (Throwable $e) {
@ -58,6 +72,7 @@ class PollMetrics extends Command
unset($clients[$server->id]);
}
$server->forceFill(['status' => 'offline'])->save();
$this->evaluateAlerts($alerts, $server, [], 'offline'); // an offline rule fires here
$this->warn("offline {$server->name}: ".$e->getMessage());
}
}
@ -75,4 +90,19 @@ class PollMetrics extends Command
return self::SUCCESS;
}
/**
* Run the alert rules for a freshly-polled server. Best-effort: an alerting/notification error
* must NEVER kill the poll loop, so any throwable is caught and logged as a warning line.
*
* @param array<string, int|float|string> $metrics
*/
private function evaluateAlerts(AlertEvaluator $alerts, Server $server, array $metrics, string $status): void
{
try {
$alerts->evaluate($server, $metrics, $status);
} catch (Throwable $e) {
$this->warn("alert eval failed {$server->name}: ".$e->getMessage());
}
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Console\Command;
/**
* Enforce the audit-log retention policy: delete audit_events older than the
* configured number of days. The policy lives in the `audit_retention_days`
* setting (managed from the Audit page); empty or 0 means "keep forever".
*
* Scheduled daily in routes/console.php.
*/
class PruneAudit extends Command
{
protected $signature = 'clusev:prune-audit';
protected $description = 'Prune audit_events older than the configured retention window';
public function handle(): int
{
$days = (int) Setting::get('audit_retention_days', '0');
if ($days <= 0) {
$this->info('Aufbewahrung unbegrenzt — nichts zu tun');
return self::SUCCESS;
}
$deleted = AuditEvent::where('created_at', '<', now()->subDays($days))->delete();
$this->info("Aufbewahrung {$days} Tage — {$deleted} Audit-Einträge gelöscht");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Emergency, shell-only recovery: resets the admin password (and optionally 2FA)
* when the operator is locked out (lost password + authenticator + backup codes).
* Requires server access, so it is a safe last resort.
*/
class ResetAdmin extends Command
{
protected $signature = 'clusev:reset-admin
{--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
{--password= : Neues Passwort (zufällig erzeugt, falls leer)}
{--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';
protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';
public function handle(): int
{
$email = (string) $this->option('email');
$user = $email !== ''
? User::where('email', $email)->first()
: (User::count() === 1 ? User::first() : null);
if (! $user) {
$this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.');
return self::FAILURE;
}
$password = (string) ($this->option('password') ?: Str::password(20));
// Rotate remember_token so any previously issued remember-me cookie is revoked.
$user->forceFill([
'password' => Hash::make($password),
'must_change_password' => false,
'remember_token' => Str::random(60),
// Restore full admin rights — this is a lockout-recovery path, so the operator must
// regain the control plane regardless of the account's prior role.
'role' => 'admin',
]);
if ($this->option('disable-2fa')) {
$user->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
'two_factor_recovery_codes' => null,
]);
// Drop security keys too — a key that prompted the emergency reset must not
// silently revive when 2FA is re-enabled.
$user->webauthnCredentials()->delete();
}
$user->save();
$this->info("Passwort für {$user->email} zurückgesetzt.");
if (! $this->option('password')) {
$this->warn("Neues Passwort (jetzt notieren): {$password}");
}
if ($this->option('disable-2fa')) {
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\MetricSample;
use App\Models\Server;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Persist a once-a-minute resource sample per server the long history the Server-Details graph
* plots. The source of truth is the `servers` DB ROW the 15s poller forceFills every tick (cpu/mem/
* disk + last_seen_at), NOT a cache: the poller and this command run in SEPARATE containers in prod,
* so a cache-backed read only worked when the cache store happened to be shared. Reading the row
* means history is collected whenever the live gauges have data (they read the same row), regardless
* of the cache driver. A server not freshly polled (never polled / offline last_seen_at stale)
* simply gets no sample (an honest gap). `load` is not on the row, so it is taken from the live cache
* best-effort when present (stored but not plotted). Prunes beyond the retention window. Scheduled
* everyMinute in routes/console.php.
*/
class SampleMetrics extends Command
{
protected $signature = 'clusev:sample-metrics {--retention=30 : Days of history to keep}';
protected $description = 'Persist a one-per-minute metric history sample per server (and prune old samples)';
public function handle(): int
{
$now = now();
// Only servers the 15s poller updated recently — a stale row means offline / no credential,
// and sampling it would plot a flatline of its last-known values. 90s tolerates a missed tick.
$fresh = $now->copy()->subSeconds(90);
$rows = [];
foreach (Server::query()->get(['id', 'cpu', 'mem', 'disk', 'last_seen_at']) as $server) {
if ($server->last_seen_at === null || $server->last_seen_at->lt($fresh)) {
continue;
}
// load is not persisted on the row; best-effort from the live cache when the store is shared.
$cached = Cache::get("metrics:latest:{$server->id}");
$rows[] = [
'server_id' => $server->id,
// Percentages — clamp to 0..100 so a stray reading can never plot outside the chart.
'cpu' => min(100, max(0, (int) $server->cpu)),
'mem' => min(100, max(0, (int) $server->mem)),
'disk' => min(100, max(0, (int) $server->disk)),
'load' => is_array($cached) ? round((float) ($cached['load'] ?? 0), 2) : 0.0,
'sampled_at' => $now,
];
}
if ($rows !== []) {
MetricSample::insert($rows);
}
$retention = max(1, (int) $this->option('retention'));
MetricSample::where('sampled_at', '<', now()->subDays($retention))->delete();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Services\DeploymentService;
use Illuminate\Console\Command;
/**
* Freeze the currently-configured panel domain as the ACTIVE one for this container's
* lifetime. Run by the entrypoint at every container start, so a dashboard domain change
* takes effect on restart (and never mid-session).
*
* The snapshot itself is the readiness probe: it returns false until the setting can be
* read authoritatively (database reachable AND the settings table migrated neither is
* guaranteed at startup: depends_on health isn't honored on host reboot, and on a fresh
* install the app starts before install.sh migrates). We retry until it succeeds, and only
* if it never does do we freeze the install-time env fallback so a snapshot file always
* exists and the active domain is never a live value that could shift after startup.
*/
class SnapshotDomain extends Command
{
protected $signature = 'clusev:snapshot-domain {--tries=30}';
protected $description = 'Snapshot the configured panel domain as the active serving domain (run at container start)';
public function handle(DeploymentService $deployment): int
{
$tries = max(1, (int) $this->option('tries'));
for ($i = 1; $i <= $tries; $i++) {
if ($deployment->snapshotActiveDomain()) {
$this->info('Active domain: '.($deployment->domain() ?? '(bare IP / HTTP)'));
return self::SUCCESS;
}
if ($i < $tries) {
sleep(1);
}
}
// Settings never became readable in time. Keep any existing snapshot (last-known-good,
// frozen); only if none exists, freeze the install-time env fallback — so the active
// domain is always a FIXED snapshot and never falls back to a live value that could
// change once the DB/migrations catch up (which would break the restart boundary).
if ($deployment->hasSnapshot()) {
$this->warn('Settings not reachable; kept the existing active-domain snapshot.');
} else {
$deployment->snapshotFallback();
$this->warn('Settings not reachable; froze the install-time domain (dashboard override applies on the next ready restart).');
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\BruteforceGuard;
use Illuminate\Console\Command;
class UnbanIp extends Command
{
protected $signature = 'clusev:unban {ip? : Zu entsperrende IP-Adresse} {--all : Alle aktiven Bänne entfernen}';
protected $description = 'Entfernt App-Level-IP-Bänne des Anmeldeschutzes per Shell (Aussperr-Rettung)';
public function handle(BruteforceGuard $guard): int
{
if ($this->option('all')) {
$count = $guard->unbanAll();
$this->info("{$count} Bann/Bänne entfernt.");
return self::SUCCESS;
}
$ip = (string) $this->argument('ip');
if ($ip === '') {
$this->error('Bitte eine IP angeben oder --all verwenden.');
return self::FAILURE;
}
$guard->unban($ip);
$this->info("Bann für {$ip} entfernt (falls vorhanden).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Models\WgTrafficSample;
use App\Services\WgStatus;
use Illuminate\Console\Command;
class WgSample extends Command
{
protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';
protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';
public function handle(WgStatus $wg): int
{
$status = $wg->read();
if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) {
$now = now();
$rows = [];
foreach ($status['peers'] ?? [] as $peer) {
$rows[] = [
'peer_pubkey' => (string) ($peer['pubkey'] ?? ''),
'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null,
'rx' => (int) ($peer['rx'] ?? 0),
'tx' => (int) ($peer['tx'] ?? 0),
'sampled_at' => $now,
];
}
if ($rows !== []) {
WgTrafficSample::insert($rows);
}
}
$retention = max(1, (int) $this->option('retention'));
WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete();
return self::SUCCESS;
}
}

37
app/Enums/Role.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Enums;
/**
* Account role. Hierarchy: admin > operator > viewer. admin has every ability; operator can run
* day-to-day operations (services, files, terminal to managed servers) but not the dangerous
* control-plane actions; viewer is read-only.
*/
enum Role: string
{
case Admin = 'admin';
case Operator = 'operator';
case Viewer = 'viewer';
/** Numeric rank for hierarchy comparisons. */
public function rank(): int
{
return match ($this) {
self::Admin => 3,
self::Operator => 2,
self::Viewer => 1,
};
}
/** True when this role is at least as privileged as $other. */
public function atLeast(self $other): bool
{
return $this->rank() >= $other->rank();
}
/** Localised label (lang/{de,en}/roles.php key role_<value>). */
public function label(): string
{
return (string) __('roles.role_'.$this->value);
}
}

View File

@ -2,8 +2,8 @@
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@ -22,9 +22,11 @@ class MetricsTicked implements ShouldBroadcast
public string $server = 'web-01',
) {}
public function broadcastOn(): Channel
public function broadcastOn(): PrivateChannel
{
return new Channel('metrics');
// PRIVATE: requires /broadcasting/auth (see routes/channels.php). Infrastructure
// metrics must not be subscribable by anyone holding the public app key.
return new PrivateChannel('metrics');
}
public function broadcastAs(): string

View File

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Thrown by DockerService::containers() when the `docker` binary is absent on the target (a native
* server with no container runtime), as opposed to a daemon-down / permission fault. Detecting this
* from the single `docker ps` call lets the pages render the honest "not installed" state WITHOUT a
* second SSH round-trip just to probe availability.
*/
class DockerNotInstalled extends RuntimeException {}

View File

@ -0,0 +1,350 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditEvent;
use App\Services\BruteforceGuard;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* Honeypot deception layer. Handles requests to decoy paths (routes/web.php, above the guest
* group) that no legitimate client ever visits /.env, /wp-login.php, /phpmyadmin, A hit is
* proof of a hostile probe, so we: audit it, instant-ban the source IP (BruteforceGuard::banNow,
* exempt/loopback are skipped), and return a small, self-contained DECEPTIVE body so the attacker
* keeps chasing the bait instead of noticing a real panel behind it. Attacker-facing strings are
* intentionally NOT localized (they impersonate generic third-party software).
*/
class HoneypotController extends Controller
{
/**
* Per-IP cap on decoy audit rows written in a rolling hour. The decoy routes run WITHOUT
* BlockBannedIp (an already-banned prober still gets the bait), so a single source can loop
* over wildcard paths indefinitely this bounds how far it can grow audit_events. The ban +
* the deception still happen past the cap; only the (redundant) extra audit rows are dropped.
*/
private const AUDIT_CAP_PER_HOUR = 60;
public function trap(Request $request): Response
{
// Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit).
if (! config('clusev.honeypot.enabled')) {
abort(404);
}
// An AUTHENTICATED user hitting a decoy is a stray request, NOT an attacker: behave like an
// ordinary 404 (no ban, no deception, no audit) so a logged-in operator/admin never self-bans.
if (auth()->check()) {
abort(404);
}
$ip = (string) $request->ip();
// An active (non-safe) method is a real attack attempt; a safe read (GET/HEAD/OPTIONS) is merely
// viewing a decoy. Only an attempt bans, so simply opening a fake page never locks anyone out of
// the real panel (an operator testing the honeypot, or a mistaken click, stays free).
//
// Use getRealMethod() — the RAW REQUEST_METHOD — NOT isMethod()/getMethod(): those honor the
// `_method` body/query field and the X-HTTP-Method-Override header, so an attacker could send a
// real POST spoofed as `_method=PUT` to make isMethod('post') false and dodge the instant-ban
// entirely (the decoys are Route::any, so the override still routes here). Ban any non-safe verb.
$isAttempt = ! in_array($request->getRealMethod(), ['GET', 'HEAD', 'OPTIONS'], true);
// BAN FIRST, and independently of the audit. The instant-ban IS the protection; it must never be
// skipped because a later step throws on hostile input. Guarded on its own so that even a ban
// failure (e.g. a momentary DB outage) still serves the deception below.
//
// We deliberately do NOT try to spare a "cross-site forged" POST by inspecting the Origin header:
// the header is attacker-controlled, so any Origin-based skip is a trivial UNIVERSAL ban-evasion
// (a scanner just sends a foreign Origin) — strictly worse than the narrow reflected-griefing it
// would prevent. Honeypot bans are time-limited and unban-able from the Threats page.
if ($isAttempt) {
try {
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
} catch (\Throwable $e) {
report($e);
}
}
// Audit the probe (best-effort, rate-capped). Fully isolated: a logging failure must not affect
// the ban above or the response — an unhandled throw here would otherwise surface a Laravel 500
// that UNMASKS the fake.
try {
$this->auditProbe($request, $ip, $isAttempt);
} catch (\Throwable $e) {
report($e);
}
return $this->deceive($request);
}
/** Record the probe as an audit event, rate-capped per source IP (see AUDIT_CAP_PER_HOUR). */
private function auditProbe(Request $request, string $ip, bool $isAttempt): void
{
// Bound audit growth per source IP WITHOUT a check-then-act race: `add` seeds the counter with a
// 1-hour TTL on the first hit only, then the atomic `increment` both counts and returns the new
// total (Redis INCR keeps the TTL). Concurrent hits can at most overshoot by the concurrency
// count — fine for a DoS bound. Past the cap we drop the redundant row (the ban already happened).
$capKey = 'honeypot:audit:'.md5($ip);
Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) > self::AUDIT_CAP_PER_HOUR) {
return;
}
// Attacker-controlled strings are scrubbed to valid UTF-8 before they reach the array-cast
// `meta` column: a raw invalid byte (e.g. a lone 0x80 in the password field or User-Agent)
// would make json_encode throw JsonEncodingException on the cast. mb_scrub replaces ill-formed
// sequences with the Unicode substitute char.
$clean = static fn (?string $v, int $n = 190): string => Str::limit(mb_scrub((string) $v), $n);
$meta = [
'ua' => $clean($request->userAgent()),
'method' => $request->method(),
'query' => $clean($request->getQueryString()),
];
// Capture the credentials a prober typed into the fake login — their guesses, which are threat
// intel (a legitimate user never reaches a decoy). The field names span the impersonated apps:
// WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the generic form.
$user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username');
$pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password');
if (is_string($user) && $user !== '') {
$meta['tried_user'] = $clean($user, 120);
}
if (is_string($pass) && $pass !== '') {
$meta['tried_pass'] = $clean($pass, 120);
}
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit',
'target' => $clean($request->path()),
'ip' => $ip,
'meta' => $meta,
]);
if ($isAttempt) {
AuditEvent::forgetThreatLoginCount(); // refresh the sidebar Threats badge immediately
}
}
/** Pick a convincing fake response for the probed path. */
private function deceive(Request $request): Response
{
$path = strtolower($request->path());
if ($path === '.env') {
return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
if ($path === '.git/config') {
return response($this->fakeGitConfig(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) {
return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
if (str_starts_with($path, 'phpmyadmin')) {
return response($this->fakePhpMyAdmin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}
/**
* Plausible-looking dotenv seeded with the per-install canaries (the bait) when configured. When a
* canary is empty (unconfigured/dev), a plausible static value is served instead so the decoy still
* looks authentic it is simply not registered as a detectable honeytoken.
*/
private function fakeDotenv(): string
{
$c = (array) config('clusev.honeypot.canaries');
$appKey = ($c['app_key'] ?? '') !== '' ? $c['app_key'] : 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=';
$dbPassword = ($c['db_password'] ?? '') !== '' ? $c['db_password'] : 'Sup3rS3cr3t-Pr0d-DB-9f2a7c';
$apiKey = ($c['api_key'] ?? '') !== '' ? $c['api_key'] : 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
return implode("\n", [
'APP_NAME=Application',
'APP_ENV=production',
'APP_KEY='.$appKey,
'APP_DEBUG=false',
'APP_URL=https://app.internal.example.com',
'',
'DB_CONNECTION=mysql',
'DB_HOST=10.20.0.14',
'DB_PORT=3306',
'DB_DATABASE=app_production',
'DB_USERNAME=app_prod',
'DB_PASSWORD='.$dbPassword,
'',
'API_KEY='.$apiKey,
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
'AWS_DEFAULT_REGION=eu-central-1',
'',
]);
}
/** Plausible fake git config (an [core]/[remote "origin"] INI) so the /.git/config decoy looks real. */
private function fakeGitConfig(): string
{
return implode("\n", [
'[core]',
"\trepositoryformatversion = 0",
"\tfilemode = true",
"\tbare = false",
"\tlogallrefupdates = true",
'[remote "origin"]',
"\turl = https://git.internal.example.com/app/backend.git",
"\tfetch = +refs/heads/*:refs/remotes/origin/*",
'[branch "main"]',
"\tremote = origin",
"\tmerge = refs/heads/main",
'',
]);
}
private function fakeWordPress(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log In &lsaquo; Blog &#8212; WordPress</title>
<style>
html{background:#f0f0f1}
body{background:#f0f0f1;color:#3c434a;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:13px;line-height:1.4;margin:0}
#login{width:320px;padding:8% 0 0;margin:auto}
#login h1{text-align:center;margin:0 0 25px}
#login h1 a{display:inline-block;line-height:0}
#login h1 svg{width:84px;height:84px;fill:#3c434a}
.screen-reader-text{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(1px,1px,1px,1px)}
#loginform{background:#fff;border:1px solid #c3c4c7;box-shadow:0 1px 3px rgba(0,0,0,.04);padding:26px 24px;margin:0 0 16px;overflow:hidden}
#loginform p{margin:0 0 16px}
#loginform label{color:#3c434a;font-size:14px;display:block;margin-bottom:3px}
#loginform .input{width:100%;box-sizing:border-box;font-size:24px;line-height:1.33;padding:3px 8px;background:#fff;border:1px solid #8c8f94;border-radius:4px;color:#2c3338;min-height:40px}
#loginform .input:focus{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1;outline:2px solid transparent}
.forgetmenot label{display:inline!important;font-size:12px}
.submit{margin:0!important;text-align:right}
.button-primary{background:#2271b1;border:1px solid #2271b1;color:#fff;border-radius:3px;padding:0 14px;height:36px;font-size:14px;line-height:34px;cursor:pointer}
#nav,#backtoblog{margin:24px 0 0;text-align:center;font-size:13px}
#nav a,#backtoblog a{color:#50575e;text-decoration:none}
#nav a:hover{color:#135e96}
</style>
</head>
<body class="login">
<div id="login">
<h1><a href="https://wordpress.org/"><svg viewBox="0 0 122.5 122.5" role="img" aria-label="WordPress"><path d="M8.7,61.3c0,20.8,12.1,38.7,29.6,47.3L13.2,39.9C10.3,46.4,8.7,53.7,8.7,61.3z M96.7,58.6c0-6.5-2.3-11-4.3-14.5c-2.6-4.3-5.1-8-5.1-12.3c0-4.8,3.7-9.3,8.9-9.3c0.2,0,0.5,0,0.7,0c-9.4-8.6-21.9-13.9-35.7-13.9c-18.5,0-34.7,9.5-44.2,23.9c1.2,0,2.4,0.1,3.3,0.1c5.5,0,14-0.7,14-0.7c2.8-0.2,3.2,4,0.3,4.3c0,0-2.9,0.3-6,0.5l19,56.5l11.4-34.2l-8.1-22.3c-2.8-0.2-5.5-0.5-5.5-0.5c-2.8-0.2-2.5-4.5,0.3-4.3c0,0,8.7,0.7,13.9,0.7c5.5,0,14-0.7,14-0.7c2.8-0.2,3.2,4,0.3,4.3c0,0-2.9,0.3-6,0.5l18.9,56.1l5.2-17.4C95.4,68.6,96.7,63.2,96.7,58.6z M62.9,65.8l-15.7,45.6c4.7,1.4,9.6,2.1,14.8,2.1c6.1,0,12-1.1,17.5-3c-0.1-0.2-0.3-0.5-0.4-0.7L62.9,65.8z M107.4,36.3c0.2,1.7,0.3,3.4,0.3,5.4c0,5.3-1,11.2-4,18.7l-16,46.2c15.6-9.1,26.1-26,26.1-45.3C113.8,52.2,111.5,43.6,107.4,36.3z M61.3,0C27.5,0,0,27.5,0,61.3s27.5,61.3,61.3,61.3s61.3-27.5,61.3-61.3S95,0,61.3,0z M61.3,119.7c-32.2,0-58.4-26.2-58.4-58.4s26.2-58.4,58.4-58.4s58.4,26.2,58.4,58.4S93.5,119.7,61.3,119.7z"/></svg><span class="screen-reader-text">Powered by WordPress</span></a></h1>
<form name="loginform" id="loginform" action="wp-login.php" method="post">
<p><label for="user_login">Username or Email Address</label>
<input type="text" name="log" id="user_login" class="input" value="" autocomplete="username"></p>
<p><label for="user_pass">Password</label>
<input type="password" name="pwd" id="user_pass" class="input" value="" autocomplete="current-password"></p>
<p class="forgetmenot"><label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever"> Remember Me</label></p>
<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary" value="Log In"></p>
</form>
<p id="nav"><a href="wp-login.php?action=lostpassword">Lost your password?</a></p>
<p id="backtoblog"><a href="/">&larr; Go to Blog</a></p>
</div>
</body>
</html>
HTML;
}
private function fakePhpMyAdmin(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>phpMyAdmin</title>
<style>
html,body{background:#f4f4f4;margin:0}
body{color:#464646;font-family:"Segoe UI",Tahoma,Verdana,Arial,sans-serif;font-size:13px}
#page{width:400px;margin:44px auto 0}
.logo{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:6px}
.logo svg{width:46px;height:46px}
.logo span{font-size:24px;color:#235a81;font-weight:bold;letter-spacing:-.5px}
.logo span b{color:#e8862a;font-weight:bold}
h1{font-size:18px;font-weight:normal;color:#235a81;text-align:center;margin:0 0 20px}
form.login fieldset{background:#fff;border:1px solid #a0a0a0;border-radius:5px;padding:6px 0 16px;margin:0}
form.login legend{background:#e5e5e5;border:1px solid #a0a0a0;border-radius:4px;padding:4px 12px;color:#235a81;font-weight:bold;margin-left:14px}
.item{padding:7px 18px;overflow:hidden}
.item label{display:inline-block;width:104px;color:#333;vertical-align:middle}
.textfield,#select_server{padding:6px;border:1px solid #a0a0a0;border-radius:3px;font-size:14px;width:210px;box-sizing:border-box;background:#fff}
.textfield:focus,#select_server:focus{border-color:#235a81;outline:2px solid #cfe0ef}
.button{margin:8px 0 0 122px;background:#235a81;color:#fff;border:1px solid #1c4a6b;border-radius:4px;padding:8px 26px;font-size:14px;cursor:pointer}
</style>
</head>
<body>
<div id="page">
<div class="logo"><svg viewBox="0 0 64 64" role="img" aria-label="database"><ellipse cx="32" cy="14" rx="22" ry="9" fill="#235a81"/><path d="M10 14v18c0 5 10 9 22 9s22-4 22-9V14c0 5-10 9-22 9s-22-4-22-9z" fill="#2f6d99"/><path d="M10 32v18c0 5 10 9 22 9s22-4 22-9V32c0 5-10 9-22 9s-22-4-22-9z" fill="#235a81"/></svg><span>php<b>My</b>Admin</span></div>
<h1>Welcome to phpMyAdmin</h1>
<form method="post" action="phpmyadmin" name="login_form" class="login">
<fieldset>
<legend>Log in</legend>
<div class="item"><label for="input_username">Username:</label><input type="text" name="pma_username" id="input_username" value="" class="textfield" autocomplete="username"></div>
<div class="item"><label for="input_password">Password:</label><input type="password" name="pma_password" id="input_password" value="" class="textfield" autocomplete="current-password"></div>
<div class="item"><label for="select_server">Server Choice:</label><select name="server" id="select_server"><option value="1">localhost:3306</option></select></div>
<input type="submit" name="submit" value="Go" class="button">
</fieldset>
</form>
</div>
</body>
</html>
HTML;
}
private function fakeGenericLogin(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<style>
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:#eef1f5;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#1a2332;display:flex;align-items:center;justify-content:center;min-height:100vh}
main{background:#fff;width:340px;padding:36px 32px 32px;border-radius:12px;box-shadow:0 6px 28px rgba(20,30,55,.10)}
.brand{display:flex;align-items:center;justify-content:center;gap:9px;margin:0 0 8px}
.brand svg{width:30px;height:30px}
.brand span{font-size:16px;font-weight:600;color:#334155}
h1{font-size:19px;font-weight:600;margin:0 0 22px;text-align:center}
label{display:block;font-size:13px;color:#5b6472;margin:0 0 6px}
input{width:100%;padding:10px 12px;border:1px solid #d5dbe3;border-radius:7px;font-size:14px;margin:0 0 16px;background:#fff}
input:focus{border-color:#2563eb;outline:2px solid #bfdbfe}
button{width:100%;padding:11px;background:#2563eb;color:#fff;border:0;border-radius:7px;font-size:14px;font-weight:600;cursor:pointer;margin-top:4px}
button:hover{background:#1d4ed8}
.foot{text-align:center;margin:16px 0 0;font-size:12px;color:#94a3b8}
</style>
</head>
<body>
<main>
<div class="brand"><svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="lock"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg><span>Admin Console</span></div>
<h1>Sign in to continue</h1>
<form method="post" action="administrator">
<label for="username">Username</label>
<input type="text" name="username" id="username" autocomplete="username">
<label for="password">Password</label>
<input type="password" name="password" id="password" autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
<p class="foot">Authorized personnel only</p>
</main>
</body>
</html>
HTML;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Hard-blocks GUEST requests from a banned IP (403). Authenticated requests pass, so an
* operator already logged in is never cut off mid-session and can unban their own IP from
* the Anmeldeschutz tab. Appended to the web group so Auth::guest() resolves (the session
* middleware has run). request()->ip() is correct via the global trustProxies (prod).
*/
class BlockBannedIp
{
public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response
{
if ($this->guard->enabled() && Auth::guest() && $this->guard->isBanned((string) $request->ip())) {
return response()->view('errors.blocked', [], 403);
}
return $next($request);
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Http\Middleware;
use App\Models\AuditEvent;
use App\Services\BruteforceGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Honeytoken tripwire. The honeypot's fake .env seeds per-install canary secret VALUES that no real
* system secret ever equals (config('clusev.honeypot.canaries')). If an UNAUTHENTICATED attacker who
* scraped the decoy .env replays one of those values as a request-BODY credential field, a bearer
* token, or an Authorization header it is undeniable proof they took the bait, so we instant-ban + 403.
*
* Deliberately CHEAP and FALSE-POSITIVE-FREE: it only trips for anonymous requests (a logged-in user
* is never a honeytoken replayer, and this stops a reflected decoy link banning a real victim), it
* scans only a fixed handful of well-known credential inputs from the request BODY (never the query
* string, never `password`) plus the bearer token / Authorization header never a recursive walk.
* A normal request not carrying a canary is a near-free no-op. Appended to the web group AFTER
* AuthenticateSession so the session/IP are resolved.
*/
class DetectHoneytoken
{
/** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */
private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key'];
/** Per-IP cap on honeytoken audit rows per rolling hour (bounds an exempt/rotating source). */
private const AUDIT_CAP_PER_HOUR = 60;
public function __construct(private BruteforceGuard $guard) {}
public function handle(Request $request, Closure $next): Response
{
if (! config('clusev.honeypot.enabled')) {
return $next($request);
}
// Only trap UNAUTHENTICATED attackers. A logged-in user is never a honeytoken replayer, and
// this stops a reflected decoy link from banning an authenticated victim.
if (auth()->check()) {
return $next($request);
}
$canaries = config('clusev.honeypot.canaries', []);
if ($canaries === []) {
return $next($request);
}
// A small, bounded set of candidate values — request BODY only (form-encoded via post() AND a
// JSON body via json()), never the query string (which a reflected link could carry to ban a
// victim), no recursion into nested arrays.
$candidates = [];
foreach (self::CANDIDATE_KEYS as $key) {
foreach ([$request->post($key), $request->json($key)] as $value) {
if (is_string($value) && $value !== '') {
$candidates[] = $value;
}
}
}
if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
$candidates[] = $bearer;
}
if (($auth = $request->header('Authorization')) !== null && $auth !== '') {
$candidates[] = $auth;
}
foreach ($canaries as $name => $canaryValue) {
if (! is_string($canaryValue) || $canaryValue === '') {
continue;
}
foreach ($candidates as $candidate) {
if (hash_equals($canaryValue, $candidate)) {
return $this->trip($request, (string) $name);
}
}
}
return $next($request);
}
private function trip(Request $request, string $canaryName): Response
{
$ip = (string) $request->ip();
// Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write
// failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks
// this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here:
// BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches
// trip() again — the honeytoken audit cannot be looped.
try {
$this->guard->banNow($ip, 'honeytoken');
} catch (\Throwable $e) {
report($e);
}
try {
// Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment
// counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so
// BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let
// a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still
// happen. Mirrors HoneypotController::auditProbe.
$capKey = 'honeytoken:audit:'.md5($ip);
Cache::add($capKey, 0, now()->addHour());
if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'security.honeytoken_used',
'target' => $canaryName,
'ip' => $ip,
// path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a
// percent-decoded invalid byte can't make the array-cast meta column's json_encode throw.
'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)],
]);
}
} catch (\Throwable $e) {
report($e);
}
return response()->view('errors.blocked', [], 403);
}
}

View File

@ -7,8 +7,10 @@ use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* After login, force the security onboarding before the panel is reachable:
* 1) rotate the seeded password, then 2) enrol 2FA.
* After login, NUDGE the operator to rotate the seeded password but do not force it. The prompt
* shows once per session and can be skipped (PasswordChange::skip sets `onboarding.password_skipped`);
* a persistent banner keeps warning while the initial password is still in use. 2FA is optional too,
* so it is never forced here.
*/
class EnsureSecurityOnboarded
{
@ -16,16 +18,10 @@ class EnsureSecurityOnboarded
{
$user = $request->user();
if ($user) {
$allowed = $request->routeIs('password.change', 'two-factor.setup', 'logout');
if ($user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
}
if (! $user->must_change_password && ! $user->hasTwoFactorEnabled() && ! $allowed) {
return redirect()->route('two-factor.setup');
}
if ($user && $user->must_change_password
&& ! $request->session()->get('onboarding.password_skipped')
&& ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
}
return $next($request);

View File

@ -0,0 +1,91 @@
<?php
namespace App\Http\Middleware;
use App\Services\DeploymentService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\Response;
/**
* Ties cookie security, HTTPS enforcement and host enforcement to the ACTUAL request so
* a panel-domain change can never lock the operator out nor expose the panel on the wrong
* host. Runs behind TrustProxies (prod), so isSecure()/getHost() reflect Caddy's
* X-Forwarded-* headers.
*
* - The session cookie's `Secure` flag follows the real request scheme, so the bare-IP /
* plain-HTTP recovery path always receives its cookie and can log in.
* - When a domain is active, ONLY that domain (over HTTPS) and the literal server IP
* (over HTTP, the recovery path) may serve the panel. Any other hostname a stale/old
* domain whose certificate Caddy may still hold, or an attacker's domain pointed at this
* server is refused, so the authenticated panel is never served in plaintext or on an
* unintended host. Caddy's TLS-ask + the health check are exempt.
* - In bare-IP mode app.url is kept in step with the request, so generated absolute URLs
* never point at a removed/old domain left in .env.
*/
class PanelScheme
{
public function __construct(private DeploymentService $deployment) {}
public function handle(Request $request, Closure $next): Response
{
// Cookie security follows the real request scheme (never a static .env flag).
config(['session.secure' => $request->isSecure()]);
$domain = $this->deployment->domain();
// Bare-IP mode: no TLS, no host restriction. Keep app.url on the current request
// so URLs don't point at a domain that was removed from service.
if ($domain === null) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
// a plain-HTTP call with an internal Host; the health check + the terminal sidecar's
// resolve call likewise come over the private network with a service-name Host).
if ($request->is('_caddy/*', 'up', '_internal/*')) {
return $next($request);
}
$host = strtolower($request->getHost());
// The literal server IP is the plaintext recovery path — always served over HTTP.
// Pin app.url to the IP request too, so login/onboarding redirects (absolute
// route() URLs) stay on the reachable IP instead of bouncing to the (possibly
// unreachable) domain that AppServiceProvider set as app.url.
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// Only the active domain may serve the panel by hostname.
if ($host !== $domain) {
abort(404);
}
// Force HTTPS for the active domain — on the default TLS port (443). Build the URL
// explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL
// (Caddy serves TLS only on 443; https://domain:8080 would be unreachable).
//
// In external-proxy mode the upstream terminates TLS and owns HTTP->HTTPS; this Caddy is
// HTTP-only, so the app must NOT bounce to https://domain:443 (unreachable). The forwarded
// scheme still drives cookie-Secure/HSTS above.
if (! $this->deployment->externalTls() && ! $request->isSecure()) {
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
}
// Serving the active domain over HTTPS: force generated URLs (assets, routes) to
// https://<domain> so they MATCH the page scheme. Critical in external-proxy mode, where
// the upstream terminates TLS and the forwarded scheme may not be trusted here — without
// this asset() emits http:// URLs that the HTTPS page's CSP ('self') blocks, leaving the
// panel with no CSS/JS. The bare-IP recovery path returned earlier and stays on HTTP.
URL::forceRootUrl('https://'.$domain);
URL::forceScheme('https');
return $next($request);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Resolve the active UI language for each request: the signed-in user's saved
* preference wins, then the session choice (so a guest on the login page can switch),
* then the app default. Only known locales are honoured.
*/
class SetLocale
{
/** Languages Clusev ships. Keep in sync with the lang/<code> directories + R16. */
public const SUPPORTED = ['de', 'en'];
public function handle(Request $request, Closure $next): Response
{
self::apply($request);
return $next($request);
}
/**
* Resolve + set the active locale: signed-in user's preference, then the session
* choice, then the app default. Safe to call before the session is started e.g.
* from the exception handler for errors thrown before this middleware (CSRF 419,
* unmatched-route 404, maintenance 503) a missing session is simply skipped, so
* those custom error pages still honour the user's language where it is known.
*/
public static function apply(Request $request): void
{
$locale = config('app.locale');
if ($request->hasSession()) {
$locale = $request->user()?->locale
?? $request->session()->get('locale')
?? $locale;
}
if (in_array($locale, self::SUPPORTED, true)) {
app()->setLocale($locale);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Guards the internal terminal-resolve endpoint: only the terminal sidecar (which holds the shared
* secret, injected from the same .env) may call it. The endpoint hands out decrypted SSH credentials,
* so it must never be reachable without the secret and it is only routed on the private network.
*/
class VerifyTerminalSidecarSecret
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('clusev.terminal_secret');
$given = (string) $request->header('X-Sidecar-Secret', '');
abort_if($expected === '' || ! hash_equals($expected, $given), 403);
return $next($request);
}
}

View File

@ -0,0 +1,259 @@
<?php
namespace App\Livewire\Alerts;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\Setting;
use App\Services\AlertNotifier;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use App\Support\MailSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Alert rules + notification channels + active incidents. Managing rules/channels =
* manage-panel (admin): route mw + mount() + a per-method gate() (since /livewire/update
* skips route mw). Viewing incidents is open (read-only). Rule deletion goes through the
* signed single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// New-rule form
public string $name = '';
public string $metric = 'cpu';
public string $comparator = 'gt';
public float $threshold = 80; // float: `load` thresholds are fractional (e.g. 1.5)
public string $scopeType = 'all';
public ?string $scopeUuid = null; // group OR server uuid, resolved on create
// Channels
public string $emailTo = '';
public string $webhooks = '';
// Gotify push: base server URL (plaintext) + app token (stored encrypted, write-only in the UI).
public string $gotifyUrl = '';
public string $gotifyToken = '';
public bool $gotifyTokenSet = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
$this->emailTo = (string) Setting::get('alert_email_to', '');
$this->webhooks = (string) Setting::get('alert_webhooks', '');
$this->gotifyUrl = (string) Setting::get('alert_gotify_url', '');
$this->gotifyTokenSet = ((string) Setting::get('alert_gotify_token', '')) !== '';
}
public function title(): string
{
return __('alerts.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function createRule(): void
{
$this->gate();
$data = $this->validate([
'name' => ['required', 'string', 'max:80'],
'metric' => ['required', Rule::in(AlertRule::METRICS)],
'comparator' => ['required', Rule::in(AlertRule::COMPARATORS)],
'threshold' => ['required', 'numeric', 'min:0', 'max:10000'], // numeric: `load` can be fractional
'scopeType' => ['required', Rule::in(AlertRule::SCOPES)],
]);
// Resolve the scope target (a group/server uuid) to its id — never trust a client-sent id.
$scopeId = null;
if ($data['scopeType'] === 'group') {
$scopeId = ServerGroup::where('uuid', $this->scopeUuid)->firstOrFail()->id;
} elseif ($data['scopeType'] === 'server') {
$scopeId = Server::where('uuid', $this->scopeUuid)->firstOrFail()->id;
}
$rule = AlertRule::create([
'name' => $data['name'],
'metric' => $data['metric'],
'comparator' => $data['comparator'],
'threshold' => $data['metric'] === 'offline' ? 0 : $data['threshold'],
'scope_type' => $data['scopeType'],
'scope_id' => $scopeId,
'enabled' => true,
]);
$this->audit('alert.rule_create', $rule->name);
$this->reset('name', 'scopeUuid');
$this->dispatch('notify', message: __('alerts.rule_created', ['name' => $rule->name]));
}
public function toggleRule(string $uuid): void
{
$this->gate();
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
$rule->update(['enabled' => ! $rule->enabled]);
$this->audit('alert.rule_update', $rule->name);
}
public function confirmDeleteRule(string $uuid): void
{
$this->gate();
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('alerts.delete_heading'),
'body' => __('alerts.delete_body', ['name' => $rule->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('alerts.rule_deleted', ['name' => $rule->name]),
'token' => ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null),
],
);
}
#[On('alertRuleDeleted')]
public function deleteRule(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'alertRuleDeleted');
} catch (InvalidConfirmToken) {
return;
}
// NOT audited here — the confirm modal wrote alert.rule_delete from the sealed token.
AlertRule::where('uuid', $payload['params']['uuid'])->first()?->delete(); // incidents cascade
}
public function saveChannels(): void
{
$this->gate();
// SSRF guard: refuse to store a webhook whose scheme isn't http(s) or whose host resolves to
// an internal/loopback/private address. Checked here (immediate feedback) and again at send.
foreach (preg_split('/[\r\n]+/', trim($this->webhooks)) ?: [] as $line) {
$line = trim($line);
if ($line !== '' && ! AlertNotifier::isSafeWebhookUrl($line)) {
$this->addError('webhooks', __('alerts.webhook_invalid', ['url' => $line]));
return;
}
}
// Gotify: scheme-checked (a self-hosted server on a LAN is allowed — no private-range block).
$gotifyUrl = trim($this->gotifyUrl);
if ($gotifyUrl !== '' && ! AlertNotifier::isValidGotifyUrl($gotifyUrl)) {
$this->addError('gotifyUrl', __('alerts.gotify_invalid'));
return;
}
$newToken = trim($this->gotifyToken);
$urlChanged = $gotifyUrl !== (string) Setting::get('alert_gotify_url', '');
// If the URL changes, DROP the old token BEFORE the new URL is stored — so no concurrent
// send can ever pair the old token with the new (possibly attacker-controlled) endpoint. A
// fresh token (if entered) is written afterwards; otherwise re-entry is required.
if ($urlChanged) {
Setting::forget('alert_gotify_token');
$this->gotifyTokenSet = false;
}
Setting::put('alert_email_to', trim($this->emailTo));
Setting::put('alert_webhooks', trim($this->webhooks));
Setting::put('alert_gotify_url', $gotifyUrl);
// Encrypt a NEWLY entered token; an empty field on an UNCHANGED url leaves the stored one.
if ($newToken !== '') {
Setting::put('alert_gotify_token', Crypt::encryptString($newToken));
$this->gotifyToken = '';
$this->gotifyTokenSet = true;
}
$this->audit('alert.channels_update', __('alerts.channels'));
$this->dispatch('notify', message: __('alerts.channels_saved'));
}
/** Remove the stored Gotify app token (write-only credential). */
public function clearGotifyToken(): void
{
$this->gate();
Setting::forget('alert_gotify_token');
$this->gotifyToken = '';
$this->gotifyTokenSet = false;
$this->audit('alert.channels_update', __('alerts.channels'));
$this->dispatch('notify', message: __('alerts.channels_saved'));
}
/** True when at least one delivery channel could actually reach someone (drives the warning).
* Reads the SAVED config with the SAME validation the sender uses, so the banner never hides
* for a channel that would be silently skipped at send time. */
private function channelsReach(): bool
{
if (MailSettings::isConfigured()) {
return true;
}
$webhooks = array_filter(
array_map('trim', preg_split('/[\r\n,]+/', (string) Setting::get('alert_webhooks', '')) ?: []),
fn (string $u) => $u !== '' && AlertNotifier::isSafeWebhookUrl($u),
);
if ($webhooks !== []) {
return true;
}
return AlertNotifier::gotifyTarget() !== null;
}
public function render(): View
{
return view('livewire.alerts.index', [
'channelsReach' => $this->channelsReach(),
'rules' => AlertRule::orderBy('name')->get(),
'incidents' => AlertIncident::with(['rule', 'server'])->where('state', 'firing')->latest('started_at')->limit(50)->get(),
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
// id → name maps so a rule's scope target renders without a per-rule query (no N+1).
'groupNames' => ServerGroup::pluck('name', 'id'),
'serverNames' => Server::pluck('name', 'id'),
'metrics' => AlertRule::METRICS,
'comparators' => AlertRule::COMPARATORS,
'scopes' => AlertRule::SCOPES,
])->title($this->title());
}
}

View File

@ -3,50 +3,151 @@
namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use Illuminate\Support\Collection;
use App\Models\Setting;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('Audit-Log — Clusev')]
class Index extends Component
{
use WithPagination;
/** Audit entries per page — keeps a long history navigable (page is deep-linked via ?page=). */
private const PER_PAGE = 25;
/** Freitext-Filter über Akteur / Aktion / Ziel / Server. */
public string $q = '';
/**
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
* Aufbewahrungsdauer in Tagen (Formularfeld). Leer/0 = unbegrenzt; ein
* positiver Wert N löscht beim täglichen Prune Einträge älter als N Tage.
*/
protected function events(): Collection
public ?string $retentionDays = null;
public function mount(): void
{
$events = AuditEvent::with('server')->latest()->limit(50)->get();
// Empty/0 means "keep forever" — surface that as a blank field, not "0".
$stored = (int) Setting::get('audit_retention_days', '0');
$this->retentionDays = $stored > 0 ? (string) $stored : null;
}
/** A changed search must restart at page 1 — never land on an out-of-range page. */
public function updatedQ(): void
{
$this->resetPage();
}
/** Persist the retention policy, audit the change and notify the operator. */
public function saveRetention(): void
{
// Panel-wide policy mutation (a short retention prunes audit history) — admin only. Viewing
// the audit log stays open to everyone; only changing the retention window is gated.
abort_unless(auth()->user()?->can('manage-panel'), 403);
$validated = $this->validate([
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
]);
$days = $validated['retentionDays'] !== null && $validated['retentionDays'] !== ''
? (int) $validated['retentionDays']
: 0;
Setting::put('audit_retention_days', $days > 0 ? (string) $days : null);
$this->retentionDays = $days > 0 ? (string) $days : null;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'audit.retention_set',
'target' => 'audit_retention_days='.$days,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('audit.retention_saved_notify'));
}
/** Effective retention in days (0 = unlimited) for the policy summary. */
protected function retentionPolicy(): int
{
return $this->retentionDays !== null && $this->retentionDays !== ''
? (int) $this->retentionDays
: 0;
}
/**
* Audit events, newest first, optionally filtered. The filter runs in SQL (so it spans the whole
* history, not just one page) and matches actor / action code / target / server name plus the
* READABLE action labels: a term is translated to the action codes whose localized label contains
* it, so searching "Endpoint geändert" still finds wg.set-endpoint entries.
*/
protected function eventsQuery(): Builder
{
$query = AuditEvent::with('server')->latest();
$needle = trim($this->q);
if ($needle === '') {
return $events;
return $query;
}
$needle = mb_strtolower($needle);
$lower = mb_strtolower($needle);
$like = '%'.$lower.'%';
$codes = array_keys(array_filter(
(array) trans('audit.actions'),
fn ($label): bool => is_string($label) && str_contains(mb_strtolower($label), $lower),
));
return $events->filter(function (AuditEvent $e) use ($needle): bool {
$haystack = mb_strtolower(implode(' ', array_filter([
$e->actor,
$e->action,
$e->target,
$e->server?->name,
])));
return $query->where(function (Builder $w) use ($like, $codes): void {
$w->whereRaw('LOWER(actor) LIKE ?', [$like])
->orWhereRaw('LOWER(action) LIKE ?', [$like])
->orWhereRaw('LOWER(target) LIKE ?', [$like])
->orWhereHas('server', fn (Builder $s) => $s->whereRaw('LOWER(name) LIKE ?', [$like]));
if ($codes !== []) {
$w->orWhereIn('action', $codes);
}
});
}
return str_contains($haystack, $needle);
})->values();
/**
* Page numbers to render, with "" gaps for long histories (1 4 5 6 20).
*
* @return array<int, int|string>
*/
protected function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, max(1, $total));
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
public function render()
{
/** @var LengthAwarePaginator $events */
$events = $this->eventsQuery()->paginate(self::PER_PAGE);
return view('livewire.audit.index', [
'events' => $this->events(),
]);
'events' => $events,
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
'retentionPolicy' => $this->retentionPolicy(),
])->title(__('audit.title'));
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
/** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';
public string $email = '';
public string $code = '';
public string $password = '';
public string $password_confirmation = '';
/** Reveals the secondary inline 2FA-proof form when a mailer is configured. */
public bool $showCodeReset = false;
/** True when a real mailer is configured (then the email-link option is offered too). */
public function mailEnabled(): bool
{
return ! in_array(config('mail.default'), ['log', 'array', null], true);
}
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
public function sendResetLink(): void
{
if (! $this->mailEnabled()) {
return;
}
$this->validate(['email' => ['required', 'email']]);
// The notification is queued (see User::sendPasswordResetNotification), so this only ever
// pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither
// latency nor an exception can distinguish a known address from an unknown one.
try {
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
} catch (\Throwable $e) {
// The client response stays identical regardless — but surface the failure to ops
// (report() → logs), otherwise a broken queue/token store silently "succeeds" for
// every address and a broken reset flow goes unnoticed.
report($e);
}
// Generic — never reveal whether the email exists.
$this->dispatch('notify', message: __('auth.reset_link_sent'));
}
/** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */
public function resetPassword()
{
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(6)],
]);
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
// path is a second TOTP/backup brute-force surface (email + code, no password), so a
// distributed (multi-IP) attempt must be capped per account the same way the challenge
// is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open).
$email = strtolower($this->email);
$key = 'forgot:'.md5($email.'|'.request()->ip());
$acctKey = 'forgot-acct:'.md5($email);
foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) {
if (RateLimiter::tooManyAttempts($k, $max)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
]);
}
}
$user = User::where('email', $this->email)->first();
// Resolve 2FA status with a *uniform* lookup pattern: always exactly one
// webauthn-existence query — for a TOTP user that wouldn't otherwise need it, and for a
// missing account — so the presence/absence of that query can't tell accounts apart.
$hasWebauthn = $user
? $user->hasWebauthnCredentials()
: WebauthnCredential::whereKey(0)->exists();
$hasTotp = (bool) $user?->hasTotp();
$has2fa = $hasTotp || $hasWebauthn;
if ($user && $has2fa) {
// Keep the TOTP HMAC uniform: a webauthn-only account has no TOTP secret, so
// verifyTotp() returns without hashing — spend an equivalent throwaway HMAC first so
// this branch costs the same as a TOTP account would.
if (! $hasTotp) {
$this->dummyTotpVerify();
}
$ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
} else {
// No account, or one without 2FA: run the same HMAC + locked recovery lookup a real
// wrong-code attempt does, so the verification path is not a timing/query oracle that
// distinguishes a known address.
$this->dummyTotpVerify();
$this->dummyRecoveryLookup();
$ok = false;
}
if (! $ok) {
RateLimiter::hit($key, 60); // 5 / minute (email+IP)
RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
// Generic message — never reveal whether the email exists.
throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
}
RateLimiter::clear($key);
RateLimiter::clear($acctKey);
// Rotate remember_token too, so a stolen remember-me cookie can't survive the reset.
$user->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
AuditEvent::create([
'user_id' => $user->id,
'actor' => $user->name ?? 'system',
'action' => 'password.reset',
'target' => $user->email,
'ip' => request()->ip(),
]);
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
/**
* Stand-in for User::verifyTotp() one throwaway Google2FA HMAC against a fixed dummy secret,
* so a request with no (or no TOTP) account spends the same crypto as a real wrong-code
* attempt. It must NOT do heavier work than the real path (a cost-12 bcrypt here would make
* this slower, re-opening the oracle in reverse and amplifying CPU DoS). Result is discarded.
*/
private function dummyTotpVerify(): void
{
try {
(new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? '');
} catch (\Throwable) {
// ignore — verifyTotp swallows the same failure
}
}
/**
* Stand-in for User::useRecoveryCode() a locked SELECT in a transaction + an in_array scan,
* matching the dominant DB cost of the recovery path without touching a real row (id 0 never
* exists, so nothing is locked). Exceptions are deliberately NOT caught: a DB failure must
* surface here exactly as it would in the real path, otherwise an outage becomes an
* error-response oracle (generic for an unknown address, 500 for a real account).
*/
private function dummyRecoveryLookup(): void
{
DB::transaction(function (): void {
$row = User::whereKey(0)->lockForUpdate()->first();
in_array($this->code, $row?->recoveryCodes() ?? array_fill(0, 8, ''), true);
});
}
public function render()
{
return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));
}
}

View File

@ -2,20 +2,28 @@
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\BruteforceGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Anmelden — Clusev')]
class Login extends Component
{
/**
* A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one
* Hash::check against this so the failure branch costs the same bcrypt as a known
* account closing the response-timing oracle that would otherwise reveal which
* addresses exist (same class as the password-reset constant-time fix).
*/
private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u';
#[Validate('required|email')]
public string $email = '';
@ -28,22 +36,51 @@ class Login extends Component
{
$this->validate();
$key = 'login:'.md5(strtolower($this->email).'|'.request()->ip());
// Three auto-expiring counters, none of which can permanently lock the control plane
// (all decay in minutes; the host CLI `clusev:reset-admin` is always available):
// - pair (email+IP): the tight 5/min bucket (unchanged).
// - ip : caps one source hammering many accounts AND the bcrypt CPU it burns.
// - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the
// single admin account is still capped instead of scaling linearly with IPs.
$email = strtolower($this->email);
$pairKey = 'login:'.md5($email.'|'.request()->ip());
$ipKey = 'login-ip:'.md5(request()->ip());
$acctKey = 'login-acct:'.md5($email);
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
]);
foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) {
if (RateLimiter::tooManyAttempts($k, $max)) {
throw ValidationException::withMessages([
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
]);
}
}
$user = User::where('email', $this->email)->first();
if (! $user || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => 'Diese Zugangsdaten passen nicht.']);
// Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend
// one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the
// faster no-account response would re-open the enumeration timing oracle.
$validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH);
if (! $user || ! $validPassword) {
RateLimiter::hit($pairKey, 60); // 5 / minute
RateLimiter::hit($ipKey, 60); // 20 / minute
RateLimiter::hit($acctKey, 900); // 30 / 15 minutes
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, 'login');
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.login_failed',
'target' => $this->email,
'ip' => $ip,
]);
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
}
RateLimiter::clear($key);
// Only the tight pair bucket is cleared on success; the broader ip/acct counters
// decay on their own so one valid login can't reset a flood's budget.
RateLimiter::clear($pairKey);
// 2FA gate: hold the login until the TOTP code is verified.
if ($user->hasTwoFactorEnabled()) {
@ -61,6 +98,6 @@ class Login extends Component
public function render()
{
return view('livewire.auth.login');
return view('livewire.auth.login')->title(__('auth.title_login'));
}
}

View File

@ -4,13 +4,13 @@ namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Passwort ändern — Clusev')]
class PasswordChange extends Component
{
public string $current = '';
@ -21,25 +21,56 @@ class PasswordChange extends Component
public function update()
{
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
], attributes: [
'current' => 'aktuelles Passwort',
'password' => 'neues Passwort',
]);
// Throttle the current-password re-auth so a hijacked session can't brute-force the
// password. Keyed on the authenticated user id (never IP) and auto-expiring, so it can
// only slow the user's own session briefly — never a cross-user or control-plane lockout.
$key = 'reauth:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'current' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
try {
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
], attributes: [
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
]);
} catch (ValidationException $e) {
if (array_key_exists('current', $e->errors())) {
RateLimiter::hit($key, 60);
}
throw $e;
}
RateLimiter::clear($key);
Auth::user()->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
])->save();
// onboarding continues: the middleware sends an un-enrolled user to 2FA setup.
return $this->redirect(route('two-factor.setup'), navigate: true);
// 2FA is optional now — go straight to the panel; 2FA is offered in Settings.
return $this->redirect(route('dashboard'), navigate: true);
}
/**
* Keep the current password for now rotation is not forced. Remember the choice for this session
* so the prompt does not reappear; `must_change_password` stays true, so the persistent default-
* password banner keeps warning until the password is actually changed.
*/
public function skip()
{
session()->put('onboarding.password_skipped', true);
return $this->redirect(route('dashboard'), navigate: true);
}
public function render()
{
return view('livewire.auth.password-change');
return view('livewire.auth.password-change')->title(__('auth.title_password_change'));
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Auth;
use App\Models\AuditEvent;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class ResetPassword extends Component
{
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
/** Consume the emailed reset token and set a new password. */
public function resetPassword()
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(6)],
]);
$status = Password::reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
AuditEvent::create([
'user_id' => $user->id,
'actor' => $user->name ?? 'system',
'action' => 'password.reset',
'target' => $user->email,
'ip' => request()->ip(),
]);
event(new PasswordReset($user));
},
);
if ($status !== Password::PasswordReset) {
throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]);
}
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.auth.reset-password')->title(__('auth.title_forgot'));
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
class TwoFactorBackup extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
public function mount()
{
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
}
public function verify()
{
$this->validate();
$this->assertNotRateLimited();
$user = $this->pendingUser();
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
// Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
if (! $user->useRecoveryCode($this->code)) {
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
public function render()
{
return view('livewire.auth.two-factor-backup')->title(__('auth.title_backup'));
}
}

View File

@ -2,20 +2,19 @@
namespace App\Livewire\Auth;
use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use App\Services\WebauthnService;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('Bestätigung — Clusev')]
class TwoFactorChallenge extends Component
{
use CompletesTwoFactorChallenge;
#[Validate('required|string')]
public string $code = '';
@ -24,47 +23,69 @@ class TwoFactorChallenge extends Component
if (! session()->has('2fa.user')) {
return $this->redirect(route('login'), navigate: true);
}
// No TOTP and webauthnAvailable() is false (no registered key, or — for a key-only user —
// no secure context on http + bare IP) → the backup code is the only path: go to its view.
if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
}
}
public function verify()
{
$this->validate();
$key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip());
$this->assertNotRateLimited();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
]);
}
$user = User::find(session('2fa.user'));
$user = $this->pendingUser();
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
// Accept the TOTP code only when the pending user actually has TOTP (otherwise a null
// secret would break verifyKey); always allow a one-time backup (recovery) code.
$valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
if (! $valid) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => 'Ungültiger Code.']);
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
RateLimiter::clear($key);
$this->clearRateLimit();
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
return $this->completeLogin($user);
}
Auth::login($user, $remember);
session()->regenerate();
/** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
public function assertionOptions(WebauthnService $webauthn): array
{
$user = User::find(session('2fa.user'));
abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);
return $this->redirectIntended(route('dashboard'), navigate: true);
return $webauthn->assertionOptions($user);
}
/** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
{
$this->assertNotRateLimited();
$user = User::find(session('2fa.user'));
if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
$this->recordFailedAttempt('2fa');
throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
}
$this->clearRateLimit();
return $this->completeLogin($user);
}
public function render()
{
return view('livewire.auth.two-factor-challenge');
return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
}
}

View File

@ -5,13 +5,12 @@ namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('2FA einrichten — Clusev')]
class TwoFactorSetup extends Component
{
public string $secret = '';
@ -21,8 +20,8 @@ class TwoFactorSetup extends Component
public function mount()
{
if (Auth::user()->hasTwoFactorEnabled()) {
return $this->redirect(route('dashboard'), navigate: true);
if (Auth::user()->hasTotp()) {
return $this->redirect(route('settings'), navigate: true);
}
$this->secret = (new Google2FA)->generateSecretKey();
@ -32,8 +31,14 @@ class TwoFactorSetup extends Component
{
$this->validate();
if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']);
try {
$matches = (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code) ?? '');
} catch (Google2FAException $e) {
$matches = false;
}
if (! $matches) {
throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]);
}
Auth::user()->forceFill([
@ -41,7 +46,21 @@ class TwoFactorSetup extends Component
'two_factor_confirmed_at' => now(),
])->save();
return $this->redirect(route('dashboard'), navigate: true);
// First factor enrolled and no codes yet → generate the single recovery set and
// ask Settings to pop the modal (the app layout hosts wire-elements/modal).
if (! Auth::user()->hasRecoveryCodes()) {
Auth::user()->replaceRecoveryCodes();
session()->flash('open_recovery_modal', true);
// put (not flash): must survive the redirect AND the later openModal Livewire
// request; the modal pulls it on mount to reveal the freshly generated codes once.
session()->put('2fa.codes_fresh', now()->timestamp);
// One-time download grant (a timestamp) — the download route requires + consumes
// it within a short window, so the fresh set can be downloaded once and a later
// direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
}
return $this->redirect(route('settings'), navigate: true);
}
public function qrCode(): string
@ -51,6 +70,6 @@ class TwoFactorSetup extends Component
public function render()
{
return view('livewire.auth.two-factor-setup');
return view('livewire.auth.two-factor-setup')->title(__('auth.title_setup'));
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Livewire\Certs;
use App\Models\AuditEvent;
use App\Models\CertEndpoint;
use App\Services\CertService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* TLS certificate-expiry monitoring for configured endpoints. `operate`-gated (route + mount +
* per-method). The expiry check is native (a PHP SSL stream in CertService no SSH/shell, so the
* host is never interpolated into a command); the lazy scan is per-endpoint guarded. Deleting an
* endpoint goes through a signed ConfirmToken + R5 modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// Add-endpoint form
public string $label = '';
public string $host = '';
public int $port = 443;
/** @var array<string, array<string, mixed>> endpoint uuid → check result (+ 'status') */
public array $checks = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('certs.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function scan(CertService $certs): void
{
$this->gate();
$this->checks = [];
foreach (CertEndpoint::orderBy('host')->get() as $ep) {
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function addEndpoint(CertService $certs): void
{
$this->gate();
$data = $this->validate([
'label' => ['nullable', 'string', 'max:60'],
// A hostname or an IP — never a scheme/path/shell char (it's used in ssl://host:port).
'host' => ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
]);
$ep = CertEndpoint::create($data);
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
// Probe the new endpoint right away so its validity shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
$this->reset('label', 'host');
$this->port = 443;
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));
}
public function confirmDeleteEndpoint(string $uuid): void
{
$this->gate();
$ep = CertEndpoint::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('certs.delete_heading'),
'body' => __('certs.delete_body', ['host' => $ep->host]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('certs.deleted', ['host' => $ep->host]),
'token' => ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host.':'.$ep->port, null),
],
);
}
#[On('certEndpointDeleted')]
public function deleteEndpoint(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'certEndpointDeleted');
} catch (InvalidConfirmToken) {
return;
}
CertEndpoint::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
public function render(): View
{
return view('livewire.certs.index', [
'endpoints' => CertEndpoint::orderBy('host')->get(),
])->title($this->title());
}
}

View File

@ -0,0 +1,214 @@
<?php
namespace App\Livewire\Commands;
use App\Models\AuditEvent;
use App\Models\Runbook;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Services\CommandRunner;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Ad-hoc fleet command / runbook runner. SECURITY-CRITICAL: arbitrary remote execution, so the whole
* page is `operate`-gated (route + mount + per-method), every run goes through a signed ConfirmToken +
* R5 confirm modal, and EVERY run is audited (command + target count). Targets resolve client UUIDs
* ids server-side; only active-credential servers are run. Output is byte-capped + scrubbed by
* CommandRunner. The command runs as the credential login user (runPlain) no extra sudo escalation.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public string $command = '';
public string $scopeType = 'all'; // all | group | servers
public ?string $scopeGroupUuid = null;
/** @var array<int, string> selected server uuids (scopeType=servers) */
public array $selectedServers = [];
// New-runbook form
public string $runbookName = '';
public string $runbookCommand = '';
/** @var array<int, array{server:string, ok:bool, output:string}> last run's results */
public array $results = [];
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('commands.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
/** Resolve the current target selection to active-credential servers (uuids → models). */
private function targetServers(): Collection
{
$q = Server::withActiveCredential();
if ($this->scopeType === 'group' && $this->scopeGroupUuid) {
$group = ServerGroup::where('uuid', $this->scopeGroupUuid)->first();
return $group ? $q->inGroup($group->id)->get() : new Collection;
}
if ($this->scopeType === 'servers') {
return $q->whereIn('uuid', $this->selectedServers)->get();
}
return $q->get(); // 'all'
}
private function issueRun(string $command): void
{
$command = trim($command);
if ($command === '') {
$this->addError('command', __('commands.command_required'));
return;
}
$servers = $this->targetServers();
if ($servers->isEmpty()) {
$this->addError('command', __('commands.no_targets'));
return;
}
$ids = $servers->pluck('id')->all();
$target = Str::limit($command, 60).' · '.__('commands.n_servers', ['count' => count($ids)]);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('commands.confirm_heading'),
'body' => __('commands.confirm_body', ['count' => count($ids), 'command' => Str::limit($command, 120)]),
'confirmLabel' => __('commands.run'),
'danger' => true,
'icon' => 'command',
'notify' => '', // the handler reports the real outcome
'token' => ConfirmToken::issue('commandRun', ['command' => $command, 'serverIds' => $ids], 'command.run', $target, null),
],
);
}
/** Ad-hoc run from the textarea. */
public function run(): void
{
$this->gate();
$this->issueRun($this->command);
}
/** Run a saved runbook (its command + the current target selection). */
public function runRunbook(string $uuid): void
{
$this->gate();
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
$this->issueRun($runbook->command);
}
#[On('commandRun')]
public function execute(string $confirmToken, CommandRunner $runner): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'commandRun');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass — no-op (the modal already audited command.run)
}
$servers = Server::whereIn('id', $payload['params']['serverIds'])->get();
$this->results = $runner->run($payload['params']['command'], $servers);
$ok = count(array_filter($this->results, fn ($r) => $r['ok']));
$this->dispatch('notify', message: __('commands.ran', ['ok' => $ok, 'total' => count($this->results)]));
}
public function createRunbook(): void
{
$this->gate();
$data = $this->validate([
'runbookName' => ['required', 'string', 'max:80', Rule::unique('runbooks', 'name')],
'runbookCommand' => ['required', 'string', 'max:4000'],
]);
$runbook = Runbook::create(['name' => $data['runbookName'], 'command' => $data['runbookCommand']]);
$this->audit('runbook.create', $runbook->name);
$this->reset('runbookName', 'runbookCommand');
$this->dispatch('notify', message: __('commands.runbook_created', ['name' => $runbook->name]));
}
public function confirmDeleteRunbook(string $uuid): void
{
$this->gate();
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('commands.delete_heading'),
'body' => __('commands.delete_body', ['name' => $runbook->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('commands.runbook_deleted', ['name' => $runbook->name]),
'token' => ConfirmToken::issue('runbookDeleted', ['uuid' => $runbook->uuid], 'runbook.delete', $runbook->name, null),
],
);
}
#[On('runbookDeleted')]
public function deleteRunbook(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'runbookDeleted');
} catch (InvalidConfirmToken) {
return;
}
Runbook::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(): View
{
return view('livewire.commands.index', [
'runbooks' => Runbook::orderBy('name')->get(),
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
])->title($this->title());
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\BruteforceGuard;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
/**
* Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
* pending-user resolver, the brute-force buckets (ONE shared counter set across both
* views), and the login success tail. Centralising it guarantees both views hit the
* same rate limit and complete login identically.
*
* @mixin Component
*/
trait CompletesTwoFactorChallenge
{
private ?User $pendingUser = null;
private bool $pendingResolved = false;
/** The pending 2FA user, resolved once per request from the session. */
protected function pendingUser(): ?User
{
if (! $this->pendingResolved) {
$this->pendingUser = User::find(session('2fa.user'));
$this->pendingResolved = true;
}
return $this->pendingUser;
}
/** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
public function getPendingHasTotpProperty(): bool
{
return (bool) $this->pendingUser()?->hasTotp();
}
/** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
public function webauthnAvailable(): bool
{
$user = $this->pendingUser();
return $user !== null
&& app(WebauthnService::class)->available()
&& $user->hasWebauthnCredentials();
}
/**
* Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
* (not client-controlled) a tight per-(user+IP) one plus an IP-independent per-user
* backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
* decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
*
* @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
*/
protected function rateLimitBuckets(): array
{
$uid = (string) session('2fa.user');
return [
'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
'two-factor-acct:'.md5($uid) => [20, 900],
];
}
protected function assertNotRateLimited(): void
{
foreach ($this->rateLimitBuckets() as $key => [$max]) {
if (RateLimiter::tooManyAttempts($key, $max)) {
throw ValidationException::withMessages([
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
}
}
protected function hitRateLimit(): void
{
foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
RateLimiter::hit($key, $decay);
}
}
protected function clearRateLimit(): void
{
foreach (array_keys($this->rateLimitBuckets()) as $key) {
RateLimiter::clear($key);
}
}
/** A failed 2FA attempt: throttle (Layer 1) + feed the persistent ban (Layer 2) + audit. */
protected function recordFailedAttempt(string $reason): void
{
$this->hitRateLimit();
$ip = request()->ip();
app(BruteforceGuard::class)->record($ip, $reason);
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.2fa_failed',
'target' => (string) session('2fa.user'),
'ip' => $ip,
]);
}
/** Log the verified pending user in and finish the challenge (shared success tail). */
protected function completeLogin(User $user): mixed
{
$remember = (bool) session('2fa.remember');
session()->forget(['2fa.user', '2fa.remember']);
Auth::login($user, $remember);
session()->regenerate();
return $this->redirectIntended(route('dashboard'), navigate: true);
}
}

View File

@ -12,9 +12,17 @@ use Illuminate\Support\Collection;
*/
trait WithFleetContext
{
/**
* Per-request memo. activeServer() calls fleet() and is itself called several times per
* render (Files ~6x, Services ~4x), so without this each call re-ran the fleet query.
* Protected, so Livewire does not serialise it into the snapshot; it resets on every
* request (the component is re-hydrated), and no page in scope mutates the fleet mid-request.
*/
protected ?Collection $fleetMemo = null;
public function fleet(): Collection
{
return Server::withExists('credential')->orderBy('name')->get();
return $this->fleetMemo ??= Server::withExists('credential')->orderBy('name')->get();
}
public function activeServer(): ?Server

View File

@ -8,18 +8,22 @@ use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Clusev — Dashboard')]
class Dashboard extends Component
{
use WithFleetContext;
public bool $ready = false;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('dashboard.page_title');
}
/** @var array<int, array> notable systemd units (lazy-loaded over SSH) */
public array $svcRows = [];
@ -124,9 +128,9 @@ class Dashboard extends Component
'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0
? $latest['disk_used'].' / '.$latest['disk_total'].' GB'
: null,
'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'),
'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);
])->title($this->title());
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Livewire\Docker;
use App\Exceptions\DockerNotInstalled;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Docker for the CLUSEV HOST the machine Clusev runs on, reached via the stored HostCredential
* over the local Docker gateway. Admin-only (manage-fleet), the same bar as the host terminal: a
* `Stop` on a control-plane container takes Clusev down. Per-SERVER container management lives on
* the server-details page (the "Docker" tab / App\Livewire\Servers\ServerDocker). Loads lazily.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
/** Docker binary absent on the host — an honest state, not an error. */
public bool $notInstalled = false;
/** No host SSH login configured yet — point the operator to setup. */
public bool $hostNotConfigured = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
public function mount(): void
{
// The host is the control-plane machine — admin-only, like the host terminal.
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('docker.title');
}
/** A transient Server pointing at the Clusev host (built from the host login), or null if unset. */
private function hostServer(): ?Server
{
return HostCredential::current()?->toServer();
}
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->hostNotConfigured = false;
$this->error = null;
$server = $this->hostServer();
if (! $server) {
$this->hostNotConfigured = true;
$this->ready = true;
return;
}
try {
// ONE SSH round-trip: containers() derives the "not installed" state from the docker ps
// call itself (a missing binary throws DockerNotInstalled), so no separate available() probe.
$this->containers = $docker->containers($server);
$this->connected = true;
} catch (DockerNotInstalled) {
$this->notInstalled = true;
} catch (Throwable $e) {
$this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$server = $this->hostServer();
if (! $server) {
return;
}
try {
$res = $docker->containerAction($server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => null, // the host is transient (no fleet row); the target label names it
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a host container (ref re-validated in DockerService). */
public function viewLogs(string $id, string $name = ''): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
// 0 is the sentinel the logs modal resolves to the Clusev host (no persisted id).
'serverId' => 0,
'ref' => $id,
'name' => $name,
]);
}
public function render(): View
{
return view('livewire.docker.index')->title($this->title());
}
}

View File

@ -4,15 +4,15 @@ namespace App\Livewire\Files;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dateien — Clusev')]
class Index extends Component
{
use WithFileUploads, WithFleetContext;
@ -60,7 +60,11 @@ class Index extends Component
return;
}
$this->path = rtrim($this->path, '/').'/'.$name;
// basename() for parity with download()/edit()/confirmDelete(): a listing entry
// name is a single path segment, never a traversal fragment. $entries is a public,
// non-#[Locked] property, so a crafted /livewire/update could otherwise set name to
// ../.. and walk outside the browsed tree.
$this->path = rtrim($this->path, '/').'/'.basename($name);
$this->load();
}
@ -80,6 +84,8 @@ class Index extends Component
/** Upload the selected file into the current directory (SFTP put). */
public function updatedUpload(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->validate(['upload' => ['file', 'max:51200']]); // 50 MB
$active = $this->activeServer();
@ -87,9 +93,9 @@ class Index extends Component
try {
$name = basename($this->upload->getClientOriginalName());
$fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath());
$this->dispatch('notify', message: "{$name}“ hochgeladen.");
$this->dispatch('notify', message: __('files.uploaded', ['name' => $name]));
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('files.upload_failed', ['error' => $e->getMessage()]));
}
}
@ -100,6 +106,11 @@ class Index extends Component
/** Stream a remote file to the browser as a download (SFTP get). */
public function download(int $index, FleetService $fleet)
{
// Reading a file's BYTES over the server credential (often root) discloses secrets a
// viewer must not reach (/etc/shadow, keys, .env). Browsing the LISTING stays open;
// pulling content requires operate, like every other file operation.
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer();
if ($name === null || ! $active || ! $active->credential_exists) {
@ -109,7 +120,7 @@ class Index extends Component
try {
$content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name));
} catch (Throwable $e) {
$this->dispatch('notify', message: $e->getMessage());
$this->dispatch('notify', message: __('files.download_failed', ['error' => $e->getMessage()]));
return null;
}
@ -120,6 +131,10 @@ class Index extends Component
/** Open the view/edit modal for a file. */
public function edit(int $index): void
{
// The editor reads (and can write) file content — an operate action. Gate here so a
// viewer never even opens the modal; FileEditor::load() re-checks on the read itself.
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer();
if ($name === null || ! $active) {
@ -167,39 +182,62 @@ class Index extends Component
*/
public function confirmDelete(int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
if ($name === null) {
return;
}
// Seal the FULL path + the server id so the token cannot be retargeted to
// another directory (client-held $path) or host (session-selected server).
$fullPath = rtrim($this->path, '/')."/{$name}";
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Datei löschen',
'body' => "{$name}“ wird unwiderruflich aus {$this->path} entfernt.",
'confirmLabel' => 'Löschen',
'heading' => __('files.delete_heading'),
'body' => __('files.delete_body', ['name' => $name, 'path' => $this->path]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'file.delete',
'auditTarget' => rtrim($this->path, '/')."/{$name}",
'event' => 'fileConfirmed',
'params' => ['name' => $name],
'notify' => "{$name}“ gelöscht.",
'notify' => __('files.delete_notify', ['name' => $name]),
'token' => ConfirmToken::issue(
'fileConfirmed',
['path' => $fullPath],
'file.delete',
$fullPath,
$this->activeServer()?->id,
),
],
);
}
/** Applies the confirmed deletion over SFTP, then reloads the directory. */
#[On('fileConfirmed')]
public function deleteEntry(string $name, FleetService $fleet): void
public function deleteEntry(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$active = $this->activeServer();
if ($active && $active->credential_exists) {
if (! $active || ($payload['serverId'] ?? null) !== $active->id) {
return; // token sealed for a different server — refuse to retarget
}
// Delete the SEALED path, never a client-recombined one.
$path = $payload['params']['path'];
if ($active->credential_exists) {
try {
$fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name);
$fleet->deleteFile($active, $path);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Löschen fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()]));
return;
}
@ -210,6 +248,6 @@ class Index extends Component
public function render()
{
return view('livewire.files.index');
return view('livewire.files.index')->title(__('files.title'));
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Livewire\Health;
use App\Models\AuditEvent;
use App\Models\HealthCheck;
use App\Services\HealthService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Uptime / health status board. `operate`-gated (route + mount + per-method). Probes run NATIVELY
* (HealthService Laravel HTTP client / a PHP TCP stream, no SSH/shell). The lazy scan is
* per-check guarded. Deleting a check goes through a signed ConfirmToken + R5 modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// Add-check form
public string $label = '';
public string $type = 'http';
public string $target = '';
public ?int $port = null;
/** @var array<string, array{ok:bool, latency:?int, detail:string}> check uuid → probe result */
public array $results = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('health.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function scan(HealthService $health): void
{
$this->gate();
$this->results = [];
foreach (HealthCheck::orderBy('label')->orderBy('target')->get() as $check) {
try {
$this->results[$check->uuid] = $health->probe($check);
} catch (Throwable $e) {
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
}
}
$this->ready = true;
}
public function addCheck(HealthService $health): void
{
$this->gate();
// HTTP → a real http(s) URL; TCP → a hostname/IP + a port. Neither is ever shelled.
$rules = [
'label' => ['nullable', 'string', 'max:60'],
'type' => ['required', Rule::in(HealthCheck::TYPES)],
];
if ($this->type === 'tcp') {
$rules['target'] = ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'];
$rules['port'] = ['required', 'integer', 'min:1', 'max:65535'];
} else {
$rules['target'] = ['required', 'url:http,https', 'max:2048'];
$rules['port'] = ['nullable'];
}
$data = $this->validate($rules);
$check = HealthCheck::create([
'label' => $data['label'] ?? null,
'type' => $data['type'],
'target' => $data['target'],
'port' => $this->type === 'tcp' ? $data['port'] : null,
]);
$this->audit('health.check_add', $data['target']);
// Probe the new check right away so its status/latency shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$this->results[$check->uuid] = $health->probe($check);
} catch (Throwable $e) {
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
}
$this->reset('label', 'target', 'port');
$this->type = 'http';
$this->dispatch('notify', message: __('health.added'));
}
public function confirmDeleteCheck(string $uuid): void
{
$this->gate();
$check = HealthCheck::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('health.delete_heading'),
'body' => __('health.delete_body', ['target' => $check->target]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('health.deleted'),
'token' => ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null),
],
);
}
#[On('healthCheckDeleted')]
public function deleteCheck(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'healthCheckDeleted');
} catch (InvalidConfirmToken) {
return;
}
HealthCheck::where('uuid', $payload['params']['uuid'])->first()?->delete();
}
public function render(): View
{
$checks = HealthCheck::orderBy('label')->orderBy('target')->get();
$up = count(array_filter($this->results, fn ($r) => $r['ok'] ?? false));
return view('livewire.health.index', [
'checks' => $checks,
'up' => $up,
'total' => count($this->results),
])->title($this->title());
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Livewire\Help;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* In-panel help/documentation. Pure display: a left topic nav (like Settings) and a per-locale
* content partial. No persistence, no external calls. Topic keys are also the content-partial
* filenames at resources/views/livewire/help/content/{de,en}/<topic>.blade.php.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Help topics in display order. */
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates', 'commands',
'servers', 'sessions', 'email', 'audit', 'wireguard', 'recovery',
];
#[Url]
public string $topic = 'overview';
// mount() handles the test/parameter-injection path (Livewire::test($class, ['topic' => …])).
// Browser deep links populate $topic via the #[Url] attribute above and bypass mount on
// hydration. Both paths are validated by the render() clamp below.
public function mount(?string $topic = null): void
{
if ($topic !== null) {
$this->topic = $topic; // clamped to a known topic in render()
}
}
public function render()
{
// Authoritative guard: any unknown topic (deep link, tampered property) → overview, so
// the view never @includes a missing content partial.
if (! in_array($this->topic, self::TOPICS, true)) {
$this->topic = 'overview';
}
$labels = [
'overview' => __('help.topic_overview'),
'domain-tls' => __('help.topic_domain_tls'),
'security' => __('help.topic_security'),
'updates' => __('help.topic_updates'),
'commands' => __('help.topic_commands'),
'servers' => __('help.topic_servers'),
'sessions' => __('help.topic_sessions'),
'email' => __('help.topic_email'),
'audit' => __('help.topic_audit'),
'wireguard' => __('help.topic_wireguard'),
'recovery' => __('help.topic_recovery'),
];
$topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS);
return view('livewire.help.index', ['topics' => $topics])->title(__('help.title'));
}
}

View File

@ -18,6 +18,8 @@ class AddSshKey extends ModalComponent
{
public int $serverId;
public string $keyName = '';
public string $publicKey = '';
public ?string $generatedPrivate = null;
@ -26,6 +28,9 @@ class AddSshKey extends ModalComponent
public function mount(int $serverId): void
{
// Installing an authorized_keys entry over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
}
@ -40,23 +45,53 @@ class AddSshKey extends ModalComponent
{
$this->error = null;
$key = EC::createKey('ed25519');
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => 'clusev-key']);
// The comment is what identifies the key in the server's list — use the operator's name.
$this->publicKey = $key->getPublicKey()->toString('OpenSSH', ['comment' => $this->keyComment() ?: 'clusev-key']);
$this->generatedPrivate = (string) $key->toString('OpenSSH');
}
/** Sanitised key label, safe as an authorized_keys comment (single line, ≤64 chars), or '' if none. */
private function keyComment(): string
{
$name = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $this->keyName) ?? '';
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
return mb_substr($name, 0, 64);
}
/** Set the OpenSSH comment of a public-key line to $comment (only when a name was given). */
private function withComment(string $pub, string $comment): string
{
if ($comment === '') {
return $pub;
}
$parts = preg_split('/\s+/', trim($pub), 3);
if (count($parts) < 2) {
return $pub; // not a recognisable key line — let the SSH layer reject it
}
return $parts[0].' '.$parts[1].' '.$comment;
}
public function save(FleetService $fleet): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
$name = $this->keyComment();
try {
$fleet->addAuthorizedKey($server, $this->publicKey);
// Force the key's comment to the operator's name so it is identifiable in the list.
$fleet->addAuthorizedKey($server, $this->withComment($this->publicKey, $name));
} catch (Throwable $e) {
$this->error = $e->getMessage();
@ -68,12 +103,12 @@ class AddSshKey extends ModalComponent
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'ssh_key.add',
'target' => $server->name,
'target' => $name !== '' ? $server->name.' · '.$name : $server->name,
'ip' => request()->ip(),
]);
$this->dispatch('keyChanged');
$this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.');
$this->dispatch('notify', message: __('modals.add_ssh_key.notify_added'));
$this->closeModal();
}

View File

@ -3,67 +3,108 @@
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
* Generic R5 confirmation dialog for destructive / stateful actions.
*
* The opener passes copy + an audit descriptor + a follow-up event. On confirm
* we persist exactly one AuditEvent and re-dispatch the opener's event so the
* originating page can apply its own state change. The modal stays
* The opener mints a signed, single-use {@see ConfirmToken} that seals the follow-up
* event + its params + the audit descriptor, and passes ONLY that token (plus display
* copy) to the modal. On confirm we re-verify the token, persist exactly one
* AuditEvent from the SEALED descriptor (never from client input), and re-dispatch the
* sealed event so the originating page can apply its own state change handing the
* same token on so the apply handler can burn it (single-use). The modal stays
* domain-agnostic and is reused for every confirm in the app (R5).
*
* Routing lives only in the token: the modal exposes no client-mutable event/params/
* audit fields, and the token itself is #[Locked] so it cannot be swapped after open.
*/
class ConfirmAction extends ModalComponent
{
public string $heading = 'Aktion bestätigen';
public string $heading = '';
public string $body = '';
public string $confirmLabel = 'Bestätigen';
public string $confirmLabel = '';
public bool $danger = false;
public string $icon = '';
/** Audit descriptor — one row written on confirm. */
public string $auditAction = '';
public ?string $auditTarget = null;
public ?int $serverId = null;
/** Follow-up event re-dispatched to the page after a successful confirm. */
public string $event = '';
/** @var array<string, mixed> */
public array $params = [];
/** Toast shown on confirm. Pass '' to defer the notification to the handler
* (e.g. when the real outcome is only known after a remote command runs). */
public string $notify = 'Aktion ausgeführt.';
* (e.g. when the real outcome is only known after a remote command runs);
* leave null to fall back to the generic default toast. */
public ?string $notify = null;
/** The sealed routing input — the ONLY thing that decides what runs/gets audited. */
#[Locked]
public string $token = '';
public function mount(
string $token,
string $heading = '',
string $body = '',
string $confirmLabel = '',
bool $danger = false,
string $icon = '',
?string $notify = null,
): void {
$this->token = $token;
$this->body = $body;
$this->danger = $danger;
$this->icon = $icon;
// Localized fallbacks for any copy the opener did not pass.
$this->heading = $heading !== '' ? $heading : __('modals.confirm_action.default_heading');
$this->confirmLabel = $confirmLabel !== '' ? $confirmLabel : __('common.confirm');
$this->notify = $notify ?? __('modals.confirm_action.default_notify');
}
public static function modalMaxWidth(): string
{
return 'md';
}
/** Audit-target line for the view — read from the signed token, not client state. */
#[Computed]
public function targetLabel(): ?string
{
return ConfirmToken::peek($this->token)['auditTarget'] ?? null;
}
public function confirm(): void
{
if ($this->auditAction !== '') {
try {
// Flip the token pending → confirmed (single transition). This IS the
// confirmation: an apply handler may only consume a confirmed token.
$payload = ConfirmToken::confirm($this->token);
} catch (InvalidConfirmToken) {
// Forged / tampered / expired / already-confirmed — never audit or dispatch.
$this->dispatch('notify', message: __('modals.confirm_action.rejected'));
$this->closeModal();
return;
}
if (($payload['auditAction'] ?? '') !== '') {
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $this->serverId,
'server_id' => $payload['serverId'] ?? null,
'actor' => Auth::user()?->name ?? 'system',
'action' => $this->auditAction,
'target' => $this->auditTarget,
'action' => $payload['auditAction'],
'target' => $payload['auditTarget'] ?? null,
'ip' => request()->ip(),
]);
}
if ($this->event !== '') {
$this->dispatch($this->event, ...$this->params);
}
// Re-dispatch the sealed event, handing the token on so the apply handler can
// burn it (single-use) and read the sealed params itself.
$this->dispatch($payload['event'], confirmToken: $this->token);
// Empty notify = the handler will report the real outcome itself.
if ($this->notify !== '') {

View File

@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Modals;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/** Read-only container log tail. Loads lazily; the container ref is re-validated in DockerService. */
class ContainerLogs extends ModalComponent
{
public int $serverId;
public string $ref;
/** Display name for the modal title (the real ref stays the docker target). */
public string $name = '';
public string $logs = '';
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId, string $ref, string $name = ''): void
{
$this->serverId = $serverId;
$this->ref = $ref;
$this->name = $name;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function load(DockerService $docker): void
{
// Re-gate the actual read (not just the opener): logs can leak secrets → operate only.
abort_unless(Auth::user()?->can('operate'), 403);
// serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server.
// The host is admin-only (manage-fleet), matching the host terminal + the Docker host target.
if ($this->serverId === 0) {
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
$server = $this->serverId === 0
? HostCredential::current()?->toServer()
: Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->loaded = true;
return;
}
try {
$this->logs = $docker->logs($server, $this->ref, 200);
} catch (InvalidArgumentException) {
$this->error = __('docker.invalid_ref');
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
$this->loaded = true;
}
public function render()
{
return view('livewire.modals.container-logs');
}
}

View File

@ -4,9 +4,12 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
@ -33,6 +36,12 @@ class CreateServer extends ModalComponent
public string $credentialName = '';
public function mount(): void
{
// Registering a server + depositing its SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
@ -54,7 +63,7 @@ class CreateServer extends ModalComponent
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
if (! $isIp && ! $isHost) {
$fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.');
$fail(__('modals.create_server.validation_ip_or_host'));
}
}],
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
@ -72,28 +81,35 @@ class CreateServer extends ModalComponent
protected function validationAttributes(): array
{
return [
'name' => 'Name',
'ip' => 'IP/Host',
'sshPort' => 'SSH-Port',
'username' => 'Benutzer',
'authType' => 'Authentifizierung',
'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort',
'credentialName' => 'Zugangs-Name',
'name' => __('modals.create_server.attr_name'),
'ip' => __('modals.create_server.attr_ip'),
'sshPort' => __('modals.create_server.attr_ssh_port'),
'username' => __('modals.create_server.attr_user'),
'authType' => __('modals.create_server.attr_auth'),
'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'),
'credentialName' => __('modals.create_server.attr_credential_name'),
];
}
public function save(): void
{
// Re-gate on save: mount()'s guard is not enough — a demotion mid-session or a
// hand-crafted /livewire/update must still be refused at the write.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
// Atomic: a failure creating the credential or audit must not leave a
// server row without its required SSH credential.
// Atomic: create server + credential, then VERIFY the SSH login. A failed
// probe throws -> the whole transaction rolls back, so no half-registered
// server is left behind, and the operator sees the real SSH reason on the
// form. A verified server starts as 'pending' ("Initialisierung") — never
// red — until the first contact promotes it.
$server = DB::transaction(function () use ($data) {
$server = Server::create([
'name' => trim($data['name']),
'ip' => trim($data['ip']),
'ssh_port' => (int) $data['sshPort'],
'status' => 'offline',
'status' => 'pending',
]);
$server->credential()->create([
@ -104,6 +120,14 @@ class CreateServer extends ModalComponent
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
]);
$server->load('credential');
$probe = app(FleetService::class)->testConnection($server);
if (! $probe['ok']) {
throw ValidationException::withMessages([
'secret' => __('modals.create_server.validation_ssh_failed', ['error' => Str::limit((string) $probe['error'], 120)]),
]);
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
@ -117,7 +141,7 @@ class CreateServer extends ModalComponent
});
$this->dispatch('serverCreated');
$this->dispatch('notify', message: 'Server hinzugefügt.');
$this->dispatch('notify', message: __('modals.create_server.notify_added'));
$this->closeModal();
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Livewire\Modals;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts
* default to `operator` least privilege). Restricted to admins (`manage-users`). The creator
* may set a password directly, or leave it blank then a strong initial password is generated, the
* account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal
* (never persisted or logged). An operator-set password is the user's own, so no forced rotation.
*/
class CreateUser extends ModalComponent
{
public string $name = '';
public string $email = '';
/** New account role (admin > operator > viewer). Defaults to operator — least privilege. */
public string $role = 'operator';
/** Optional operator-set password; blank = generate an initial password and reveal it once. */
public string $password = '';
/** Clear-text temp password, held in component state only to reveal it once. */
public ?string $tempPassword = null;
/** Name of the account just created, used in the reveal copy. */
public string $createdName = '';
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
// Optional — when set it must meet the same strength as a self-chosen password.
'password' => ['nullable', 'string', Password::min(6)],
'role' => ['required', Rule::enum(Role::class)],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'name' => __('accounts.name_label'),
'email' => __('accounts.email_label'),
'password' => __('accounts.password_label'),
'role' => __('accounts.role_label'),
];
}
public function save(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$data = $this->validate();
// Re-check uniqueness server-side regardless of the live validator state.
$email = mb_strtolower(trim($data['email']));
if (User::where('email', $email)->exists()) {
$this->addError('email', __('validation.unique', ['attribute' => __('accounts.email_label')]));
return;
}
// Operator-set password = the user's own login (no forced rotation). Blank = generate a strong
// initial password that the user is prompted to rotate, and reveal it once below.
$operatorSet = $this->password !== '';
$password = $operatorSet ? $this->password : Str::password(16);
$user = User::create([
'name' => trim($data['name']),
'email' => $email,
'password' => Hash::make($password),
'must_change_password' => ! $operatorSet,
'role' => $data['role'],
]);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'user.create',
'target' => $user->email,
'ip' => request()->ip(),
]);
// Operator already knows the password they set → no reveal; just confirm and close.
if ($operatorSet) {
$this->dispatch('usersChanged');
$this->dispatch('notify', message: __('accounts.created_notify', ['name' => $user->name]));
$this->closeModal();
return;
}
// Reveal the generated initial password ONCE — swaps the form for the reveal panel.
$this->createdName = $user->name;
$this->tempPassword = $password;
}
/** "Fertig": reload the list and close. */
public function finish(): void
{
$this->dispatch('usersChanged');
$this->closeModal();
}
public function render()
{
return view('livewire.modals.create-user');
}
}

View File

@ -29,6 +29,9 @@ class EditCredential extends ModalComponent
public function mount(int $serverId): void
{
// Depositing/updating a server's SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
if ($cred = Server::find($serverId)?->credential) {
@ -46,21 +49,24 @@ class EditCredential extends ModalComponent
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
if (trim($this->username) === '' || trim($this->secret) === '') {
$this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.';
$this->error = __('modals.edit_credential.error_required');
return;
}
if (mb_strlen(trim($this->name)) > 120) {
$this->error = 'Name darf höchstens 120 Zeichen lang sein.';
$this->error = __('modals.edit_credential.error_name_too_long');
return;
}
@ -84,7 +90,7 @@ class EditCredential extends ModalComponent
]);
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: 'Zugangsdaten gespeichert.');
$this->dispatch('notify', message: __('modals.edit_credential.notify_saved'));
$this->closeModal();
}

View File

@ -32,6 +32,8 @@ class Fail2banBan extends ModalComponent
*/
public function mount(int $serverId, array $jails = []): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->jails = array_values($jails);
$this->jail = $this->jails[0] ?? 'sshd';
@ -44,11 +46,13 @@ class Fail2banBan extends ModalComponent
public function save(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -62,7 +66,7 @@ class Fail2banBan extends ModalComponent
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.';
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.fail2ban_ban.error_ban_failed');
return;
}
@ -77,7 +81,7 @@ class Fail2banBan extends ModalComponent
]);
$this->dispatch('fail2banChanged');
$this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.');
$this->dispatch('notify', message: __('modals.fail2ban_ban.notify_banned', ['ip' => trim($this->ip)]));
$this->closeModal();
}

View File

@ -0,0 +1,124 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\Fail2banService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Scrollable list of fail2ban jails + their currently-banned IPs, lifted out of the
* servers/show panel so a long ban list never stretches the page. mount() only stores
* the serverId so the modal opens INSTANTLY; load() (fired by wire:init, behind a
* skeleton) does the live jail-status read via Fail2banService (the SAME service the
* page uses). unban() calls Fail2banService::unban (the SAME path as Show::unbanIp),
* audits the success, refreshes this modal's list, and dispatches `fail2banChanged`
* so the page panel re-reads too.
*/
class Fail2banBans extends ModalComponent
{
#[Locked]
public int $serverId;
/** Jail status rows (name/currentlyBanned/currentlyFailed/bannedIps) for the list. */
public array $jails = [];
/** True once the live status was read (distinguishes "no bans" from "not loaded"). */
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
// Store the id ONLY so the modal opens instantly; the SSH read happens in load().
$this->serverId = $serverId;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
/** Lazy: the SSH read runs after the modal renders (wire:init), behind a skeleton. */
public function load(Fail2banService $fail2ban): void
{
$this->loadJails($fail2ban);
}
/** Read the live jail/banned-IP status (same Fail2banService::status the page calls). */
private function loadJails(Fail2banService $fail2ban): void
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$status = $fail2ban->status($server);
$this->jails = $status['jails'] ?? [];
$this->error = ! empty($status['readError']) ? __('servers.fail2ban_read_error') : null;
$this->loaded = true;
} catch (Throwable $e) {
$this->error = Str::limit($e->getMessage(), 120);
}
}
/**
* Unban one IP mirrors Show::unbanIp: call Fail2banService::unban, audit the
* success, refresh this list, and dispatch `fail2banChanged` so the page panel
* re-reads its own fail2ban state.
*/
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$res = $fail2ban->unban($server, $jail, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'fail2ban.unban',
'target' => $ip.' · '.$jail.' · '.$server->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
// Re-read this modal's list, then tell the page panel to re-pull its own state.
$this->loadJails($fail2ban);
$this->dispatch('fail2banChanged');
}
public function render()
{
return view('livewire.modals.fail2ban-bans');
}
}

View File

@ -35,11 +35,13 @@ class Fail2banConfig extends ModalComponent
public function mount(int $serverId, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -64,10 +66,12 @@ class Fail2banConfig extends ModalComponent
public function save(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
// Never overwrite the remote policy with defaults the operator never saw —
// saving is only allowed once the current values were read successfully.
if (! $this->loaded) {
$this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.';
$this->error = __('modals.fail2ban_config.error_read_locked');
return;
}
@ -83,17 +87,17 @@ class Fail2banConfig extends ModalComponent
'maxretry' => ['required', 'integer', 'min:1', 'max:100'],
'findtime' => $duration,
], [
'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
'bantime.regex' => __('modals.fail2ban_config.error_bantime_invalid'),
'findtime.regex' => __('modals.fail2ban_config.error_findtime_invalid'),
], [
'bantime' => 'Sperrdauer',
'maxretry' => 'Max. Fehlversuche',
'findtime' => 'Zeitfenster',
'bantime' => __('modals.fail2ban_config.attr_bantime'),
'maxretry' => __('modals.fail2ban_config.attr_maxretry'),
'findtime' => __('modals.fail2ban_config.attr_findtime'),
]);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -114,7 +118,7 @@ class Fail2banConfig extends ModalComponent
}
if (! $result['ok']) {
$this->error = 'Speichern fehlgeschlagen. '.$result['output'];
$this->error = __('modals.fail2ban_config.error_save_failed', ['output' => $result['output']]);
return;
}
@ -129,7 +133,7 @@ class Fail2banConfig extends ModalComponent
]);
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: 'fail2ban konfiguriert.');
$this->dispatch('notify', message: __('modals.fail2ban_config.notify_saved'));
$this->closeModal();
}

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use App\Support\Ssh\Sftp;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
@ -12,9 +13,16 @@ use Throwable;
/**
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
* binary or oversized files, and writes back on save with an AuditEvent.
* Image files are shown as a read-only base64 preview instead of the editor.
*/
class FileEditor extends ModalComponent
{
/** Image preview is capped — larger images are refused (base64 inflates ~33%). */
private const IMAGE_MAX_BYTES = 5_242_880; // 5 MB
/** Extensions rendered as an inline image preview (read-only). */
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
public int $serverId;
public string $path;
@ -31,6 +39,9 @@ class FileEditor extends ModalComponent
public ?string $error = null;
/** base64 `data:` URI for image previews; null for text files. */
public ?string $dataUri = null;
public function mount(int $serverId, string $path, string $name): void
{
$this->serverId = $serverId;
@ -43,21 +54,55 @@ class FileEditor extends ModalComponent
return 'lg';
}
public function load(FleetService $fleet): void
/** True when the opened file is an image (preview, not editable). */
public function isImage(): bool
{
return in_array($this->extension(), self::IMAGE_EXTENSIONS, true);
}
private function extension(): string
{
return strtolower(pathinfo($this->name, PATHINFO_EXTENSION));
}
/** MIME type for the image preview data URI, keyed off the extension. */
private function imageMime(): string
{
return match ($this->extension()) {
'svg' => 'image/svg+xml',
'jpg', 'jpeg' => 'image/jpeg',
'ico' => 'image/x-icon',
default => 'image/'.$this->extension(),
};
}
public function load(FleetService $fleet, Sftp $sftp): void
{
// The actual content read (SFTP get / readFile). Gate it directly — not just the
// Files::edit entry point — so file bytes the server credential can reach (root →
// /etc/shadow, keys, .env) are never disclosed to a read-only viewer.
abort_unless(auth()->user()?->can('operate'), 403);
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
$this->loaded = true;
return;
}
try {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
if ($this->isImage()) {
$this->loadImage($server, $sftp);
} else {
$r = $fleet->readFile($server, $this->path);
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
// Never bind raw non-UTF-8 bytes to a public property: Livewire JSON-encodes the
// snapshot and invalid UTF-8 makes json_encode fail, so the client receives an empty
// response ("undefined is not valid JSON"). Binary/oversize files show a notice instead.
$this->content = ($r['binary'] || $r['tooBig']) ? '' : $r['content'];
}
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -65,13 +110,49 @@ class FileEditor extends ModalComponent
$this->loaded = true;
}
/**
* Read the image bytes over SFTP and expose them as a base64 data URI.
* Refuses files larger than IMAGE_MAX_BYTES (checked before download).
*/
private function loadImage(Server $server, Sftp $sftp): void
{
$sftp->connect($server);
try {
if ($sftp->size($this->path) > self::IMAGE_MAX_BYTES) {
$this->tooBig = true;
return;
}
$bytes = $sftp->get($this->path);
if ($bytes === '') {
$this->error = __('modals.file_editor.image_read_failed');
return;
}
$this->dataUri = 'data:'.$this->imageMime().';base64,'.base64_encode($bytes);
} finally {
$sftp->disconnect();
}
}
public function save(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->error = null;
// Images are a read-only preview — never write them back.
if ($this->isImage()) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -94,7 +175,7 @@ class FileEditor extends ModalComponent
]);
$this->dispatch('fileSaved');
$this->dispatch('notify', message: '„'.$this->name.'" gespeichert.');
$this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name]));
$this->closeModal();
}

View File

@ -33,6 +33,8 @@ class FirewallRule extends ModalComponent
public function mount(int $serverId, string $tool = 'ufw'): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw';
}
@ -44,11 +46,13 @@ class FirewallRule extends ModalComponent
public function save(FirewallService $firewall): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -64,7 +68,7 @@ class FirewallRule extends ModalComponent
}
if (! $res['ok']) {
$this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.';
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.firewall_rule.error_add_failed');
return;
}
@ -79,7 +83,7 @@ class FirewallRule extends ModalComponent
]);
$this->dispatch('firewallChanged');
$this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.');
$this->dispatch('notify', message: __('modals.firewall_rule.notify_added'));
$this->closeModal();
}
@ -87,7 +91,10 @@ class FirewallRule extends ModalComponent
private function summary(?int $port): string
{
if ($this->tool === 'firewalld') {
return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet';
return __('modals.firewall_rule.summary_port_opened', [
'port' => (string) $port,
'proto' => $this->proto === 'udp' ? 'udp' : 'tcp',
]);
}
$parts = [$this->action];
@ -95,7 +102,7 @@ class FirewallRule extends ModalComponent
$parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : '');
}
if (trim($this->from) !== '') {
$parts[] = 'von '.trim($this->from);
$parts[] = __('modals.firewall_rule.summary_from', ['source' => trim($this->from)]);
}
return implode(' ', $parts);

View File

@ -41,13 +41,15 @@ class HardeningAction extends ModalComponent
public function mount(int $serverId, string $action, bool $enable): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->serverId = $serverId;
$this->action = $action;
$this->enable = $enable;
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -68,13 +70,15 @@ class HardeningAction extends ModalComponent
public function apply(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if ($this->error !== null || $this->done) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -91,23 +95,25 @@ class HardeningAction extends ModalComponent
$this->done = true;
$this->ok = $result['ok'];
// Clean result only — no raw command output on success; a short reason on failure.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
// Clean result only — no raw command output on success; a bounded reason on failure. 400,
// not 200: our own guard messages (e.g. backend.ssh_root_self_lockout, 202 chars) must
// never be cut mid-sentence — the box wraps with break-words, so length is fine.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 400);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' '.__('modals.hardening_action.audit_failed_suffix')),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->dispatch('hardeningApplied');
$this->dispatch('notify', message: $this->heading.' angewendet.');
$this->dispatch('notify', message: __('modals.hardening_action.notify_applied', ['action' => $this->heading]));
} else {
$this->dispatch('notify', message: $this->heading.' fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.hardening_action.notify_failed', ['action' => $this->heading]));
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: configure the SSH login for the "Clusev host" terminal. The target is ALWAYS the local
* machine Clusev runs on (the Docker host, reached at host.docker.internal fixed, not a free-form
* host, because an arbitrary machine is what fleet servers are for). The operator only supplies the
* login (user + password/key, and the port). Stores a single encrypted HostCredential; on edit a
* blank secret keeps the stored one, and the clear-text secret is never echoed back into the form.
*/
class HostShell extends ModalComponent
{
/** The host terminal always targets the local machine; not operator-editable. */
private const HOST = 'host.docker.internal';
public int $port = 22;
public string $username = 'root';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
/** True when a HostCredential already exists — then a blank secret keeps the stored one. */
public bool $configured = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// The host SSH login is the Clusev machine itself — the most dangerous credential.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$this->configured = true;
$this->port = $hc->port;
$this->username = $hc->username;
$this->authType = $hc->auth_type;
// secret/passphrase stay blank — never reveal the stored login back into the form.
}
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'username' => ['required', 'string', 'max:120'],
'authType' => ['required', Rule::in(['password', 'key'])],
// On first setup the secret is required; when editing an existing login a blank
// secret means "keep the stored one".
'secret' => [$this->configured ? 'nullable' : 'required', 'string'],
'passphrase' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'port' => __('terminal.host_field_port'),
'username' => __('terminal.host_field_user'),
'authType' => __('terminal.host_field_auth'),
'secret' => $this->authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password'),
];
}
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
$existing = HostCredential::current();
// Switching the auth method (password ↔ key) requires a fresh secret: keeping the stored one
// would feed e.g. an old password into key auth (or vice versa) and silently break the login.
// A fresh secret is required whenever the auth method OR the target (username/port) changes:
// keeping the stored secret would feed an old password into key auth (or vice versa) and
// silently break the login, or reuse one account's secret against a different account/port.
$targetChanged = $existing !== null && (
$this->authType !== $existing->auth_type
|| trim($this->username) !== $existing->username
|| (int) $data['port'] !== $existing->port
);
if ($this->secret === '' && $targetChanged) {
throw ValidationException::withMessages([
'secret' => __('terminal.host_secret_required_for_auth_change'),
]);
}
$hc = $existing ?? new HostCredential;
$hc->host = self::HOST; // always the local machine — not operator-editable
$hc->port = (int) $data['port'];
$hc->username = trim($data['username']);
$hc->auth_type = $data['authType'] === 'key' ? 'key' : 'password';
// Only overwrite the secret when a new one was entered (blank = keep the stored one).
if ($this->secret !== '') {
$hc->secret = $this->secret;
$hc->passphrase = $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null;
}
$hc->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.configure',
'target' => $hc->username.'@'.$hc->host.':'.$hc->port,
'ip' => request()->ip(),
]);
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_saved'));
$this->closeModal();
}
public function remove(): void
{
// Re-gate on remove: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$target = $hc->username.'@'.$hc->host.':'.$hc->port;
HostCredential::query()->delete(); // singleton — clear any/all rows, not just the first
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.remove',
'target' => $target,
'ip' => request()->ip(),
]);
}
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_removed'));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.host-shell');
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
* a replayed/stale snapshot has nothing to re-render and fires no event captured codes
* cannot be re-revealed by replaying a signed snapshot.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// One-time, server-set flag (a timestamp put on first-factor enrollment). pull() reads
// + forgets, so a refresh of the manage view will no longer reveal the codes. mount()
// does not run on hydrate, so a replayed snapshot never re-triggers this. The flag is
// valid only within [now-600s, now]: a stale flag (enrollment whose modal never opened)
// or a non-timestamp value reveals nothing.
$freshAt = session()->pull('2fa.codes_fresh');
$age = is_int($freshAt) ? now()->timestamp - $freshAt : null;
if ($age !== null && $age >= 0 && $age <= 600) {
$this->dispatchCodes();
}
}
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
Auth::user()->replaceRecoveryCodes();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'two_factor.recovery_regenerate',
'target' => Auth::user()->email,
'ip' => request()->ip(),
]);
// One-time download grant (a timestamp) for the freshly regenerated set, consumed by
// the download route within a short window. Without it a later direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
// Reveal the freshly generated set once, via the transient event (never a property).
$this->dispatchCodes();
$this->dispatch('notify', message: __('auth.recovery_regenerated'));
}
public function render()
{
return view('livewire.modals.recovery-codes');
}
/**
* Deliver the current user's recovery codes to the browser via a one-time event. The
* codes never enter a Livewire property or the snapshot Alpine holds them in JS memory
* only, so a replayed snapshot cannot re-render them.
*/
protected function dispatchCodes(): void
{
$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Server;
use App\Services\SshKeyProvisioner;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
* Confirm + run the safe "generate key → install → verify → switch credential → disable
* password login" flow (R5). On success the freshly generated private key is revealed ONCE
* (download client-side; it is never re-fetchable). The key is also kept encrypted in the vault.
*/
class SshKeyProvision extends ModalComponent
{
#[Locked]
public int $serverId;
public string $serverName = '';
public bool $done = false;
public bool $ok = false;
public ?string $privateKey = null;
public ?string $publicKey = null;
public ?string $error = null;
public function mount(int $serverId): void
{
// Rotating to key-only access (installs a key, switches the credential, disables
// password login) over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$this->serverName = Server::find($serverId)?->name ?? '';
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function run(SshKeyProvisioner $provisioner): void
{
// Re-gate on run: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->done) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
return;
}
try {
$result = $provisioner->enableKeyOnlyAccess($server);
} catch (\Throwable $e) {
$this->done = true;
$this->ok = false;
$this->error = $e->getMessage();
// credentialChanged already triggers the FULL page reload on Show (key list +
// credential + security state) — no extra hardeningApplied needed.
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: __('modals.ssh_key_provision.notify_failed'));
return;
}
$this->done = true;
$this->ok = $result['ok'];
$this->privateKey = $result['privateKey'] ?? null;
$this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok
? __('modals.ssh_key_provision.notify_ok', ['server' => $this->serverName])
: __('modals.ssh_key_provision.notify_failed'));
}
public function render()
{
return view('livewire.modals.ssh-key-provision');
}
}

View File

@ -42,11 +42,14 @@ class SystemUpdate extends ModalComponent
public function mount(int $serverId, MaintenanceService $maintenance): void
{
// Applying apt/dnf/zypper package updates on a managed host over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -69,13 +72,16 @@ class SystemUpdate extends ModalComponent
public function upgrade(MaintenanceService $maintenance): void
{
// Re-gate on upgrade: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->error !== null || $this->done || ! $this->supported) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->error = __('common.server_not_found');
return;
}
@ -86,7 +92,7 @@ class SystemUpdate extends ModalComponent
$this->done = true;
$this->ok = false;
$this->output = $e->getMessage();
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
return;
}
@ -102,15 +108,15 @@ class SystemUpdate extends ModalComponent
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.package_upgrade',
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
'target' => $server->name.($this->ok ? '' : ' '.__('modals.system_update.audit_failed_suffix')),
'ip' => request()->ip(),
]);
if ($this->ok) {
$this->pending = 0;
$this->dispatch('notify', message: 'System aktualisiert.');
$this->dispatch('notify', message: __('modals.system_update.notify_updated'));
} else {
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Onboarding;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
/**
* First-run onboarding tour. Renders a dimmed spotlight overlay (the Alpine island in the view)
* that walks the operator through the panel. It auto-opens once when the account has never
* dismissed it and can be relaunched any time from Settings (a client-side window event, no
* server round-trip). `markSeen` stamps the account so it never auto-opens again.
*/
class Tour extends Component
{
/** True on the first panel load for an account that has never finished/skipped the tour. */
public bool $autostart = false;
public function mount(): void
{
$this->autostart = Auth::user()?->onboarding_tour_completed_at === null;
}
/** Persist that the tour has been finished or skipped, so it won't auto-open again. */
public function markSeen(): void
{
$user = Auth::user();
if ($user !== null && $user->onboarding_tour_completed_at === null) {
$user->forceFill(['onboarding_tour_completed_at' => now()])->save();
}
}
/**
* The tour steps. A null `target` is a centred card (welcome / finish); a non-null `target`
* matches a `data-tour="…"` attribute on a sidebar nav item and is spotlighted.
*
* @return array<int, array<string, string|null>>
*/
public function steps(): array
{
return [
['target' => null, 'icon' => 'command', 'eyebrow' => __('onboarding.welcome_eyebrow'), 'title' => __('onboarding.welcome_title'), 'body' => __('onboarding.welcome_body')],
['target' => 'dashboard', 'icon' => 'dashboard', 'eyebrow' => __('onboarding.dashboard_eyebrow'), 'title' => __('onboarding.dashboard_title'), 'body' => __('onboarding.dashboard_body')],
['target' => 'servers', 'icon' => 'server', 'eyebrow' => __('onboarding.servers_eyebrow'), 'title' => __('onboarding.servers_title'), 'body' => __('onboarding.servers_body')],
['target' => 'terminal', 'icon' => 'terminal', 'eyebrow' => __('onboarding.terminal_eyebrow'), 'title' => __('onboarding.terminal_title'), 'body' => __('onboarding.terminal_body')],
['target' => 'settings', 'icon' => 'settings', 'eyebrow' => __('onboarding.settings_eyebrow'), 'title' => __('onboarding.settings_title'), 'body' => __('onboarding.settings_body')],
['target' => null, 'icon' => 'shield', 'eyebrow' => __('onboarding.done_eyebrow'), 'title' => __('onboarding.done_title'), 'body' => __('onboarding.done_body')],
];
}
public function render()
{
return view('livewire.onboarding.tour', ['steps' => $this->steps()]);
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Patch;
use App\Models\Server;
use App\Services\MaintenanceService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Fleet patch view: pending + security update counts per server, one-click patch. `operate`-gated
* (route + mount + per-method). Counts scan is lazy + per-server guarded. Applying upgrades is a
* stateful action a signed ConfirmToken + R5 modal (the modal audits patch.apply from the sealed
* token; the handler does not re-audit). Targets resolve a client uuid the sealed server id.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array{pending:?int, security:?int}|array{error:string}> uuid → counts */
public array $counts = [];
/** @var array<string, array{ok:bool, output:string}> uuid → last patch result */
public array $results = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('patch.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function scan(MaintenanceService $maint): void
{
$this->gate();
$this->counts = [];
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable $e) {
$this->counts[$server->uuid] = ['error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function patch(string $uuid): void
{
$this->gate();
$server = Server::withActiveCredential()->where('uuid', $uuid)->firstOrFail();
$c = $this->counts[$uuid] ?? [];
$pending = $c['pending'] ?? '?';
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('patch.confirm_heading'),
'body' => __('patch.confirm_body', ['server' => $server->name, 'count' => $pending]),
'confirmLabel' => __('patch.apply'),
'danger' => true,
'icon' => 'rotate',
'notify' => '', // the handler reports the outcome
'token' => ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id),
],
);
}
#[On('patchApply')]
public function applyPatch(string $confirmToken, MaintenanceService $maint): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'patchApply');
} catch (InvalidConfirmToken) {
return; // forged / replayed — the modal already audited patch.apply
}
$server = Server::where('uuid', $payload['params']['uuid'])->first();
if (! $server) {
return;
}
$res = $maint->applyUpgrades($server);
$res['output'] = mb_scrub($res['output'], 'UTF-8'); // remote output into a Livewire prop → scrub invalid bytes
$this->results[$server->uuid] = $res;
// Refresh this server's counts so the badge reflects the post-patch state.
try {
$this->counts[$server->uuid] = $maint->updateCounts($server);
} catch (Throwable) {
// keep the old counts if the re-scan fails
}
$this->dispatch('notify', message: $res['ok']
? __('patch.patched', ['server' => $server->name])
: __('patch.patch_failed', ['error' => $res['output']]));
}
public function render(): View
{
// Fleet roll-up (only over successfully-scanned servers).
$scored = array_filter($this->counts, fn ($c) => isset($c['pending']) && $c['pending'] !== null);
$totalPending = array_sum(array_map(fn ($c) => $c['pending'], $scored));
$totalSecurity = array_sum(array_map(fn ($c) => $c['security'] ?? 0, $scored));
return view('livewire.patch.index', [
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
'totalPending' => (int) $totalPending,
'totalSecurity' => (int) $totalSecurity,
])->title($this->title());
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Livewire\Posture;
use App\Models\Server;
use App\Services\PostureService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Fleet security-posture scoreboard. Scores each active-credential server from its hardening state
* (PostureService). Reading a server's posture exposes its hardening GAPS, so the page is
* `operate`-gated (route + mount). The scan is lazy (wire:init) and per-server guarded one
* unreachable host shows an error row instead of blocking the whole scan.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array<string, mixed>> server uuid → score result (or ['error' => msg]) */
public array $scores = [];
public bool $ready = false;
public function mount(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
}
public function title(): string
{
return __('posture.title');
}
public function scan(PostureService $posture): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$this->scores = [];
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
try {
$this->scores[$server->uuid] = $posture->score($server);
} catch (Throwable $e) {
$this->scores[$server->uuid] = ['error' => $e->getMessage()];
}
}
$this->ready = true;
}
public function render(): View
{
$scored = array_filter($this->scores, fn ($s) => isset($s['score']));
$avg = $scored !== [] ? (int) round(array_sum(array_column($scored, 'score')) / count($scored)) : null;
return view('livewire.posture.index', [
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
'average' => $avg,
])->title($this->title());
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace App\Livewire\Release;
use App\Models\AuditEvent;
use App\Services\PipelineStatus;
use App\Services\PromotionService;
use App\Services\ReleaseBridge;
use App\Services\ReleasePlanner;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Dev-only Release control. "Deploy to Staging" cuts a release candidate of an allowed target
* version via the host release bridge (bump commit tag push to Gitea). The page is reachable only when
* config('clusev.release_controls') is true; mount() re-guards as defence-in-depth.
*
* Deploy-to-Staging is irreversible (it pushes a tag the host CI acts on), so it is gated behind the
* shared R5 wire-elements/modal confirm: a button calls confirmDeployStaging($target) openConfirm,
* and applyDeployStaging consumes the sealed ConfirmToken and runs deployStaging(). deployStaging()
* stays the executor (the tests call it directly).
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** The request id of an in-flight staging release; non-null drives the wire:poll. */
public ?string $pendingId = null;
/** Unix seconds when the request was issued, to time out a silent host. */
public ?int $pendingSince = null;
/** The target X.Y.Z being staged (for display while it runs). */
public ?string $pendingTarget = null;
/** The tag of the last successfully-pushed staging release candidate (shown until the next deploy). */
public ?string $lastTag = null;
public function mount(): void
{
abort_unless((bool) config('clusev.release_controls'), 404);
abort_unless(auth()->user()?->can('manage-panel'), 403);
}
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging release candidate of $target. */
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$this->openConfirm('releaseStaged', ['target' => $target],
__('release.confirm_title'), __('release.confirm_body', ['target' => $target]),
__('release.confirm_action'), danger: true, icon: 'tag');
}
/** Apply handler — called after the ConfirmAction modal confirms the staging release. */
#[On('releaseStaged')]
public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releaseStaged');
} catch (InvalidConfirmToken) {
return;
}
$target = (string) ($payload['params']['target'] ?? '');
if ($target === '') {
return;
}
$this->deployStaging($target, $planner, $bridge);
}
/** Cut + push a release candidate of $target via the host bridge. $target must be one of the proposed values. */
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$key = 'release-stage:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600);
$id = $bridge->requestStaging($target);
if ($id === null) {
$this->dispatch('notify', message: __('release.write_failed'), level: 'error');
return;
}
$this->pendingId = $id;
$this->pendingSince = time();
$this->pendingTarget = $target;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release',
'target' => '→ '.$target,
'ip' => request()->ip(),
]);
}
/** Poll the host result while a release runs (driven by wire:poll). Times out a silent host. */
public function pollResult(ReleaseBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time();
$res = $bridge->result($this->pendingId);
if ($res === null) {
if (time() - $this->pendingSince > 60) {
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
$this->dispatch('notify', message: __('release.no_host_response'), level: 'error');
}
return;
}
if ($res['ok']) {
$this->lastTag = (string) $res['tag'];
$this->dispatch('notify', message: __('release.staged', ['tag' => (string) $res['tag']]));
} else {
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release_failed',
'target' => (string) ($this->pendingTarget ?? '').' ('.$res['message'].')',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('release.failed', ['reason' => $res['message']]), level: 'error');
}
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
}
/** wire:poll target while the pipeline is in flight drops the cached step results so the next
* render re-fetches the live GitHub status. */
public function pollPipeline(PipelineStatus $pipeline): void
{
$pipeline->refresh();
}
/** The current in-flight release-candidate tag (vX.Y.Z-rcN), or null when the running version is stable. */
private function currentRcTag(): ?string
{
$v = (string) config('clusev.version');
return str_contains($v, '-rc') ? 'v'.ltrim($v, 'vV') : null;
}
/** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */
private function throttlePromote(): bool
{
$key = 'promote:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return false;
}
RateLimiter::hit($key, 600);
return true;
}
/** Record a promotion outcome (action on success, action.'_failed' on failure). */
private function auditPromotion(string $action, string $target, bool $ok): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $ok ? $action : $action.'_failed',
'target' => $target,
'ip' => request()->ip(),
]);
}
public function confirmDeployPublic(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$tag = $this->currentRcTag();
if ($tag === null) {
return;
}
$this->openConfirm('releasePublic', ['tag' => $tag],
__('release.public_confirm_title'), __('release.public_confirm_body', ['tag' => $tag]),
__('release.public_action'), danger: false, icon: 'tag');
}
#[On('releasePublic')]
public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releasePublic');
} catch (InvalidConfirmToken) {
return;
}
$tag = (string) ($payload['params']['tag'] ?? '');
if ($tag === '' || $this->currentRcTag() !== $tag || ! $this->throttlePromote()) {
return;
}
$ok = $promotion->deployPublic($tag);
$this->auditPromotion('deploy.public', $tag, $ok);
$this->dispatch('notify',
message: $ok ? __('release.public_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
level: $ok ? 'info' : 'error');
}
public function confirmYank(string $tag, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($tag, $promotion->publicTags(), true)) {
$this->dispatch('notify', message: __('release.yank_unknown'), level: 'error');
return;
}
$this->openConfirm('releaseYank', ['tag' => $tag],
__('release.yank_confirm_title'), __('release.yank_confirm_body', ['tag' => $tag]),
__('release.yank_action'), danger: true, icon: 'trash');
}
#[On('releaseYank')]
public function applyYank(string $confirmToken, PromotionService $promotion): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'releaseYank');
} catch (InvalidConfirmToken) {
return;
}
$tag = (string) ($payload['params']['tag'] ?? '');
if ($tag === '' || ! in_array($tag, $promotion->publicTags(), true) || ! $this->throttlePromote()) {
return;
}
$ok = $promotion->yank($tag);
$this->auditPromotion('deploy.yank', $tag, $ok);
$this->dispatch('notify',
message: $ok ? __('release.yank_dispatched', ['tag' => $tag]) : __('release.promote_failed'),
level: $ok ? 'info' : 'error');
}
/**
* Open the shared ConfirmAction modal (R5) for the staging release. The issued token carries NO
* audit descriptor applyDeployStaging deployStaging audits exactly once itself.
*
* @param array<string, string> $params
*/
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => $body,
'confirmLabel' => $confirmLabel,
'danger' => $danger,
'icon' => $icon,
'notify' => '',
'token' => ConfirmToken::issue($event, $params),
],
);
}
public function render()
{
$current = (string) config('clusev.version');
return view('livewire.release.index', [
'current' => $current,
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
'pipeline' => app(PipelineStatus::class)->forTrackedRc(),
'publicTags' => app(PromotionService::class)->publicTags(),
])->title(__('release.title'));
}
}

View File

@ -18,7 +18,19 @@ class ServerSwitcher extends Component
session(['active_server_id' => $server->id]);
$this->redirect($this->safeReturnUrl(), navigate: true);
$this->redirect($this->targetFor($server, $this->safeReturnUrl()), navigate: true);
}
/**
* Where to land after selecting a server. On a server-details page, switching should
* VIEW the chosen server (so the URL, sidebar and scoped context never diverge);
* everywhere else just reload the current page so the active context updates.
*/
public function targetFor(Server $server, string $current): string
{
return preg_match('#^/servers/[^/]+#', $current) === 1
? route('servers.show', $server)
: $current;
}
/**

View File

@ -0,0 +1,159 @@
<?php
namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Manage server groups (create / rename / recolour / delete + membership). Admin-only
* (manage-fleet, matching server create/delete): the route mw gates it AND every mutating
* method re-checks, since /livewire/update does not re-run route middleware. Every change is
* audited; deletion goes through the signed, single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Groups extends Component
{
/** New-group form. */
public string $name = '';
public string $color = 'accent';
public function mount(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('groups.title');
}
private function gate(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function create(): void
{
$this->gate();
$data = $this->validate([
'name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')],
'color' => ['required', Rule::in(ServerGroup::COLORS)],
]);
$group = ServerGroup::create($data);
$this->audit('group.create', $group->name);
$this->reset('name');
$this->color = 'accent';
$this->dispatch('notify', message: __('groups.created', ['name' => $group->name]));
}
public function rename(string $uuid, string $name): void
{
$this->gate();
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$name = trim($name);
$validated = validator(
['name' => $name],
['name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')->ignore($group->id)]],
)->validate();
$group->update(['name' => $validated['name']]);
$this->audit('group.update', $group->name);
}
public function setColor(string $uuid, string $color): void
{
$this->gate();
if (! in_array($color, ServerGroup::COLORS, true)) {
return; // saving() would coerce it anyway; refuse the bad token outright
}
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$group->update(['color' => $color]);
$this->audit('group.update', $group->name);
}
/** Add or remove a server from a group (checkbox toggle in the assignment grid). */
public function toggleMember(string $groupUuid, string $serverUuid): void
{
$this->gate();
$group = ServerGroup::where('uuid', $groupUuid)->firstOrFail();
$server = Server::where('uuid', $serverUuid)->firstOrFail();
$group->servers()->toggle($server->id);
$this->audit('group.members', $group->name);
}
public function confirmDelete(string $uuid): void
{
$this->gate();
$group = ServerGroup::where('uuid', $uuid)->firstOrFail();
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('groups.delete_heading'),
'body' => __('groups.delete_body', ['name' => $group->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'notify' => __('groups.deleted', ['name' => $group->name]),
// The confirm modal writes the group.delete AuditEvent from this sealed descriptor,
// so deleteGroup() must NOT audit again (avoids a double row).
'token' => ConfirmToken::issue('groupConfirmed', ['uuid' => $group->uuid], 'group.delete', $group->name, null),
],
);
}
#[On('groupConfirmed')]
public function deleteGroup(string $confirmToken): void
{
$this->gate();
try {
$payload = ConfirmToken::consume($confirmToken, 'groupConfirmed');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$group = ServerGroup::where('uuid', $payload['params']['uuid'])->first();
if (! $group) {
return;
}
// NOT audited here — the confirm modal already wrote group.delete from the sealed token.
$group->delete(); // pivot rows cascade; servers are untouched
}
public function render(): View
{
return view('livewire.servers.groups', [
'groups' => ServerGroup::withCount('servers')->with('servers:id,uuid')->orderBy('name')->get(),
'servers' => Server::orderBy('name')->get(['id', 'uuid', 'name']),
'colors' => ServerGroup::COLORS,
])->title($this->title());
}
}

View File

@ -3,19 +3,29 @@
namespace App\Livewire\Servers;
use App\Models\Server;
use App\Models\ServerGroup;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Server — Clusev')]
class Index extends Component
{
/** Live search over name / IP. */
public string $search = '';
/** Optional group filter (a ServerGroup uuid, shareable via the query string). */
#[Url]
public ?string $group = null;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('servers.index_title');
}
/** A freshly added server should appear without a manual reload (render() re-queries). */
#[On('serverCreated')]
public function refreshServers(): void
@ -27,7 +37,11 @@ class Index extends Component
{
$term = trim($this->search);
// Resolve the group filter; an unknown/removed uuid falls back to "all" (never errors).
$activeGroup = $this->group ? ServerGroup::where('uuid', $this->group)->first() : null;
$servers = Server::orderBy('name')
->when($activeGroup, fn ($query) => $query->inGroup($activeGroup->id))
->when($term !== '', function ($query) use ($term) {
$query->where(function ($q) use ($term) {
$q->where('name', 'like', "%{$term}%")
@ -44,10 +58,12 @@ class Index extends Component
return view('livewire.servers.index', [
'servers' => $servers,
'groups' => ServerGroup::withCount('servers')->orderBy('name')->get(),
'activeGroup' => $activeGroup,
'total' => (int) $counts->sum(),
'online' => (int) ($counts['online'] ?? 0),
'warning' => (int) ($counts['warning'] ?? 0),
'offline' => (int) ($counts['offline'] ?? 0),
]);
])->title($this->title());
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Livewire\Servers;
use App\Exceptions\DockerNotInstalled;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Component;
use Throwable;
/**
* Per-server container management, rendered as the "Docker" tab of the server-details page (the
* sidebar Docker page targets the Clusev host instead). Viewing the list is open to any role;
* actions (start/stop/restart) require `operate`, matching systemd service gating. Loads lazily.
*/
class ServerDocker extends Component
{
public Server $server;
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
/** Docker binary absent on the server (e.g. a native mail server) — honest state, not an error. */
public bool $notInstalled = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->error = null;
// credential_exists is a withExists() query attribute that does NOT survive Livewire model
// hydration, so check the relation directly here.
if ($this->server->credential()->exists()) {
try {
// ONE SSH round-trip: a missing docker binary throws DockerNotInstalled (derived from
// the docker ps call), so no separate available() probe is needed.
$this->containers = $docker->containers($this->server);
$this->connected = true;
} catch (DockerNotInstalled) {
$this->notInstalled = true;
} catch (Throwable $e) {
$this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
}
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('operate'), 403);
if (! $this->server->credential()->exists()) {
return;
}
try {
$res = $docker->containerAction($this->server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $this->server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$this->server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a container on this server (ref re-validated in DockerService). */
public function viewLogs(string $id, string $name = ''): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
'serverId' => (int) $this->server->id,
'ref' => $id,
'name' => $name,
]);
}
public function render()
{
return view('livewire.servers.server-docker');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Livewire\Servers;
use App\Models\Server;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Component;
/**
* Per-server web terminal, rendered as the "Terminal" tab of the server-details page (the sidebar
* Terminal page is the Clusev HOST shell instead). Mints a single-use token that the xterm.js island
* uses to open a WebSocket to the terminal sidecar; this component never touches SSH or credentials.
* A server shell is a day-to-day operator action (the host shell is the admin-only sidebar page).
*/
class ServerTerminal extends Component
{
public Server $server;
/** Mint a single-use token for this server and hand it to the xterm island via an event. */
public function open(): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$token = Str::random(48);
TerminalSession::create([
'token' => $token,
'user_id' => Auth::id(),
'kind' => 'server',
'server_id' => $this->server->id,
'expires_at' => now()->addSeconds(60),
'created_at' => now(),
]);
$this->dispatch('terminal-open', token: $token, title: $this->server->name);
}
public function render()
{
return view('livewire.servers.server-terminal');
}
}

View File

@ -8,22 +8,33 @@ use App\Services\Fail2banService;
use App\Services\FirewallService;
use App\Services\FleetService;
use App\Services\HardeningService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use App\Support\Os\OsDetector;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Server-Details — Clusev')]
class Show extends Component
{
/** Route-model-bound by uuid (R11). */
public Server $server;
/** Active detail tab: 'overview' | 'docker' | 'terminal' (deep-linkable via ?tab=). */
#[Url]
public string $tab = 'overview';
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('servers.show_title');
}
public bool $connected = false;
public bool $ready = false;
@ -57,6 +68,13 @@ class Show extends Component
*/
public function mount(): void
{
// Viewing a server's details makes it the ACTIVE server, so the sidebar switcher
// (and the scoped sections) reflect the server on screen. Set ONLY at mount — i.e.
// once per deliberate navigation — so a slow in-flight load() or a parallel tab
// can never write a stale value over a newer selection (the single-session active
// server is last-writer-wins; we keep writes to deliberate navigations only).
session(['active_server_id' => $this->server->id]);
// identity + gauges render from the persisted row immediately;
// the live snapshot loads lazily via wire:init -> load().
}
@ -143,6 +161,10 @@ class Show extends Component
/** Refresh the live gauges from the poller-updated row (wire:poll). */
public function pollMetrics(): void
{
// Only the Übersicht tab shows live metrics; skip the refresh/re-render on Docker/Terminal.
if ($this->tab !== 'overview') {
return;
}
$this->server->refresh();
}
@ -152,6 +174,8 @@ class Show extends Component
*/
public function confirmKeyRemoval(int $index): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$key = $this->sshKeys[$index] ?? null;
if ($key === null) {
return;
@ -163,29 +187,45 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'SSH-Schlüssel entfernen',
'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.",
'confirmLabel' => 'Entfernen',
'heading' => __('servers.confirm_remove_key_heading'),
'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'ssh_key.remove',
'auditTarget' => "{$comment} · {$this->server->name}",
'serverId' => $this->server->id,
'event' => 'keyRemoved',
'params' => ['fingerprint' => $fingerprint],
'notify' => "Schlüssel „{$comment}“ entfernt.",
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
'token' => ConfirmToken::issue(
'keyRemoved',
['fingerprint' => $fingerprint],
'ssh_key.remove',
"{$comment} · {$this->server->name}",
$this->server->id,
),
],
);
}
/** Applies the confirmed key removal over SSH, then reloads the list. */
#[On('keyRemoved')]
public function removeKey(string $fingerprint, FleetService $fleet): void
public function removeKey(string $confirmToken, FleetService $fleet): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'keyRemoved');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$fingerprint = $payload['params']['fingerprint'];
try {
$fleet->removeAuthorizedKey($this->server, $fingerprint);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => $e->getMessage()]));
return;
}
@ -228,6 +268,8 @@ class Show extends Component
*/
public function toggleCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -246,7 +288,7 @@ class Show extends Component
]);
$this->server->refresh();
$this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.');
$this->dispatch('notify', message: $disable ? __('servers.notify_access_locked') : __('servers.notify_access_unlocked'));
}
/**
@ -255,6 +297,8 @@ class Show extends Component
*/
public function confirmDeleteCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -263,25 +307,40 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Zugang löschen',
'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.",
'confirmLabel' => 'Löschen',
'heading' => __('servers.confirm_delete_credential_heading'),
'body' => __('servers.confirm_delete_credential_body', ['server' => $this->server->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'credential.delete',
'auditTarget' => $this->server->name.' · '.$cred->username,
'serverId' => $this->server->id,
'event' => 'credentialDeleted',
'params' => [],
'notify' => 'Zugang gelöscht.',
'notify' => __('servers.notify_access_deleted'),
'token' => ConfirmToken::issue(
'credentialDeleted',
[],
'credential.delete',
$this->server->name.' · '.$cred->username,
$this->server->id,
),
],
);
}
/** Applies the confirmed credential deletion, then refreshes the row. */
#[On('credentialDeleted')]
public function deleteCredential(): void
public function deleteCredential(string $confirmToken): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'credentialDeleted');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$this->server->credential()->delete();
$this->server->refresh();
}
@ -291,11 +350,45 @@ class Show extends Component
* checklist + firewall row reflect the new state.
*/
#[On('hardeningApplied')]
public function reloadSnapshot(FleetService $fleet): void
public function reloadSnapshot(): void
{
// A hardening/fail2ban toggle changes only the SECURITY reads. Re-probe the OS profile
// (a firewall "Aktivieren" may have just INSTALLED the tool the panels key off) and
// re-read the three security states — but keep the page rendered (no skeleton flip)
// and skip the full metrics/volumes/keys snapshot: that halves the SSH round-trips
// and the operator sees the page update in place instead of a long blank reload.
// The Sicherheit/Firewall panels show a wire:loading cue targeting this method.
$this->server->refresh();
$this->ready = false;
$this->load($fleet);
try {
$profile = app(OsDetector::class)->detect($this->server, fresh: true);
$this->os = [
'familyLabel' => $profile->familyLabel(),
'packageManager' => $profile->packageManager,
'firewallTool' => $profile->firewallTool,
'serviceManager' => $profile->serviceManager,
];
} catch (Throwable) {
// keep the current profile on failure
}
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
try {
$this->firewall = app(FirewallService::class)->status($this->server);
} catch (Throwable) {
$this->firewall = [];
}
try {
$this->fail2ban = app(Fail2banService::class)->status($this->server);
} catch (Throwable) {
$this->fail2ban = [];
}
}
/**
@ -304,6 +397,8 @@ class Show extends Component
*/
public function confirmDeleteRule(int $index): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$rule = $this->firewall['rules'][$index] ?? null;
if ($rule === null) {
return;
@ -314,42 +409,60 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Firewall-Regel entfernen',
'body' => "Die Regel „{$label}“ wird aus der Firewall von {$this->server->name} entfernt.",
'confirmLabel' => 'Entfernen',
'heading' => __('servers.confirm_remove_rule_heading'),
'body' => __('servers.confirm_remove_rule_body', ['label' => $label, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
// No premature audit/notify — the handler records the real outcome
// only after the remote command returns (audit reflects success).
'event' => 'firewallRuleDelete',
'params' => ['spec' => $spec, 'label' => $label],
'notify' => '',
'token' => ConfirmToken::issue(
'firewallRuleDelete',
['spec' => $spec, 'label' => $label],
serverId: $this->server->id,
),
],
);
}
/** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */
#[On('firewallRuleDelete')]
public function deleteFirewallRule(string $spec, string $label, FirewallService $firewall): void
public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the network must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
if (($payload['serverId'] ?? null) !== $this->server->id) {
return; // token sealed for a different server — refuse to retarget
}
$spec = $payload['params']['spec'];
$label = $payload['params']['label'];
try {
$res = $firewall->deleteRule($this->server, $spec);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
// The rule was already gone — inform, refresh, but do NOT audit a non-event.
if (! empty($res['notFound'])) {
$this->dispatch('notify', message: 'Regel existiert nicht mehr.');
$this->dispatch('notify', message: __('servers.notify_rule_not_found'));
$this->reloadFirewall($firewall);
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
@ -363,7 +476,7 @@ class Show extends Component
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: 'Firewall-Regel entfernt.');
$this->dispatch('notify', message: __('servers.notify_firewall_rule_removed'));
$this->reloadFirewall($firewall);
}
@ -381,27 +494,31 @@ class Show extends Component
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$res = $fail2ban->unban($this->server, $jail, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
$this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name);
$this->dispatch('notify', message: "IP {$ip} entsperrt.");
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
public function addIgnore(Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$ip = trim($this->newIgnoreIp);
if ($ip === '') {
return;
@ -410,12 +527,12 @@ class Show extends Component
try {
$res = $fail2ban->addIgnoreIp($this->server, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
@ -428,22 +545,24 @@ class Show extends Component
$this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name);
$this->newIgnoreIp = '';
$this->dispatch('notify', message: "{$ip} zur Whitelist hinzugefügt.");
$this->dispatch('notify', message: __('servers.notify_ip_whitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
/** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
@ -454,7 +573,7 @@ class Show extends Component
}
$this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name);
$this->dispatch('notify', message: "{$ip} von der Whitelist entfernt.");
$this->dispatch('notify', message: __('servers.notify_ip_unwhitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
@ -484,6 +603,6 @@ class Show extends Component
public function render()
{
return view('livewire.servers.show');
return view('livewire.servers.show')->title($this->title());
}
}

View File

@ -3,20 +3,23 @@
namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Services\FleetService;
use Illuminate\Support\Str;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dienste — Clusev')]
class Index extends Component
{
use WithFleetContext;
/** Cap on retained journal rows so the live poll never grows the payload unbounded. */
private const JOURNAL_MAX = 200;
/** Active server name (from fleet context). */
public string $server = '—';
@ -33,6 +36,9 @@ class Index extends Component
/** @var array<int, array{time:string,unit:string,level:string,text:string}> */
public array $journal = [];
/** journald cursor to resume the live poll from (null until the first read). */
public ?string $journalCursor = null;
public function mount(): void
{
$this->server = $this->activeServer()?->name ?? '—';
@ -48,6 +54,7 @@ class Index extends Component
$data = $fleet->systemd($active);
$this->services = $data['services'];
$this->journal = $data['journal'];
$this->journalCursor = $data['cursor'] ?? null;
$this->connected = true;
} catch (Throwable) {
$this->connected = false;
@ -58,61 +65,128 @@ class Index extends Component
}
/**
* Service control (R5): opens the wire-elements/modal confirm dialog. The
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
* Live journal: poll for entries since the last cursor and APPEND them, so no line
* is dropped between ticks (a true `journalctl -f` stream needs a PTY deferred to
* the web terminal). Caps the retained rows and survives a transient SSH hiccup.
*/
public function confirm(string $op, int $index): void
public function pollJournal(FleetService $fleet): void
{
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
if (! $this->ready || ! $this->connected) {
return;
}
[$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'],
};
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.",
'confirmLabel' => $label,
'danger' => $op === 'stop',
'icon' => $icon,
'auditAction' => $action,
'auditTarget' => "{$name} · {$this->server}",
'event' => 'serviceConfirmed',
'params' => ['op' => $op, 'name' => $name],
'notify' => "{$name}: {$label} ausgeführt.",
],
);
}
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
#[On('serviceConfirmed')]
public function applyService(string $op, string $name, FleetService $fleet): void
{
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return;
}
try {
// Fetch ceiling == display ceiling: never under-fill, never carry more than we show.
$data = $fleet->journalSince($active, $this->journalCursor, self::JOURNAL_MAX);
} catch (Throwable) {
return; // keep the current view; retry on the next tick
}
if ($data['journal'] !== []) {
$this->journal = array_slice(
array_merge($this->journal, $data['journal']),
-self::JOURNAL_MAX
);
}
$this->journalCursor = $data['cursor'];
}
/**
* Service control (R5): opens the wire-elements/modal confirm dialog. The
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
*/
public function confirm(string $op, int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
return;
}
[$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => [__('services.confirm_start_heading'), __('services.confirm_start_phrase'), __('services.confirm_start_label'), 'service.start', 'power'],
'stop' => [__('services.confirm_stop_heading'), __('services.confirm_stop_phrase'), __('services.confirm_stop_label'), 'service.stop', 'power'],
default => [__('services.confirm_restart_heading'), __('services.confirm_restart_phrase'), __('services.confirm_restart_label'), 'service.restart', 'rotate'],
};
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => __('services.confirm_body', ['name' => $name, 'phrase' => $phrase, 'server' => $this->server]),
'confirmLabel' => $label,
'danger' => $op === 'stop',
'icon' => $icon,
'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]),
'token' => ConfirmToken::issue(
'serviceConfirmed',
['op' => $op, 'name' => $name],
$action,
"{$name} · {$this->server}",
$this->activeServer()?->id,
),
],
);
}
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
#[On('serviceConfirmed')]
public function applyService(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$op = $payload['params']['op'];
$name = $payload['params']['name'];
$active = $this->activeServer();
if (! $active || ($payload['serverId'] ?? null) !== $active->id || ! $active->credential_exists) {
return; // wrong/absent server (retarget attempt) or no credential — no-op
}
try {
$res = $fleet->serviceAction($active, $op, $name);
} catch (Throwable $e) {
$this->dispatch('notify', message: "{$name}: ".$e->getMessage());
$this->dispatch('notify', message: __('services.action_error', ['name' => $name, 'error' => $e->getMessage()]));
return;
}
$this->dispatch('notify', message: $res['ok']
? "{$name}: {$op} ausgeführt."
: "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90));
if ($res['ok']) {
$this->dispatch('notify', message: __('services.action_done', ['name' => $name, 'op' => $op]));
} else {
// A toast is ephemeral and truncated — persist the FULL failure reason so the operator
// can re-read WHY a start failed (e.g. a service that crashes on launch). The confirm
// modal already logged the neutral attempt (service.<op>); this red entry records the
// outcome with the complete systemctl output in meta, shown expandable in the audit log.
AuditEvent::create([
'user_id' => auth()->id(),
'server_id' => $active->id,
'actor' => auth()->user()?->name ?? 'system',
'action' => 'service.'.$op.'.failed',
'target' => $name.' · '.$active->name,
'ip' => request()->ip(),
'meta' => ['output' => mb_substr(mb_scrub(trim((string) ($res['output'] ?: __('services.no_permission')))), 0, 4000)],
]);
$this->dispatch('notify',
message: __('services.action_failed_short', ['name' => $name]),
level: 'error');
}
try {
$this->services = $fleet->systemd($active)['services'];
@ -139,6 +213,6 @@ class Index extends Component
public function render()
{
return view('livewire.services.index');
return view('livewire.services.index')->title(__('services.title'));
}
}

View File

@ -0,0 +1,267 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Component;
use Throwable;
/**
* SMTP configuration card. The operator points the panel at an SMTP server so
* password-reset mails (and future notifications) actually leave the box. Values
* are stored as Setting rows and applied to runtime config by AppServiceProvider::boot().
*
* The SMTP password is the one secret here: it is stored ENCRYPTED at rest
* (Crypt::encryptString, APP_KEY) Setting::put stores plaintext, so we encrypt the
* value ourselves never rendered back to the browser, and never logged/echoed
* (any error from the test-send is shown without the password).
*/
class Email extends Component
{
public string $mail_host = '';
public int $mail_port = 587;
public string $mail_username = '';
/** New password typed in the form. Empty = keep the stored one (never pre-filled). */
public string $mail_password = '';
public string $mail_encryption = 'tls';
public string $mail_from_address = '';
public string $mail_from_name = '';
/** True once a password has been stored, so the field shows a "(set)" placeholder. */
public bool $passwordSet = false;
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->mail_host = Setting::get('mail_host', '') ?? '';
$this->mail_port = (int) (Setting::get('mail_port', '587') ?? 587);
$this->mail_username = Setting::get('mail_username', '') ?? '';
$this->mail_encryption = Setting::get('mail_encryption', 'tls') ?? 'tls';
$this->mail_from_address = Setting::get('mail_from_address', '') ?? '';
$this->mail_from_name = Setting::get('mail_from_name', '') ?? '';
$this->passwordSet = (Setting::get('mail_password') ?? '') !== '';
}
protected function rules(): array
{
return [
'mail_host' => ['required', 'string', 'max:255'],
'mail_port' => ['required', 'integer', 'min:1', 'max:65535'],
'mail_username' => ['nullable', 'string', 'max:255'],
'mail_password' => ['nullable', 'string', 'max:255'],
'mail_encryption' => ['required', 'in:none,tls,ssl'],
'mail_from_address' => ['required', 'email', 'max:255'],
'mail_from_name' => ['required', 'string', 'max:255'],
];
}
public function save(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate();
// Retarget guard: if the SMTP HOST or USERNAME changes and no new password was entered, DROP
// the stored password BEFORE the new host is written — so the old credential can never be
// sent to a new (possibly attacker-controlled) SMTP server; re-entry is then required. Same
// class as the alert Gotify token guard. uncached() reads the committed state (no stale cache).
$targetChanged = $this->mail_host !== (string) Setting::uncached('mail_host', '')
|| $this->mail_username !== (string) Setting::uncached('mail_username', '');
if ($targetChanged) {
// Drop the old password BEFORE any target field is written — regardless of whether a
// fresh one was typed — so no concurrent MailSettings::apply() can ever read the new
// host paired with the old password. A freshly typed password is re-written below.
Setting::forget('mail_password');
$this->passwordSet = false;
}
Setting::put('mail_host', $this->mail_host);
Setting::put('mail_port', (string) $this->mail_port);
Setting::put('mail_username', $this->mail_username);
Setting::put('mail_encryption', $this->mail_encryption);
Setting::put('mail_from_address', $this->mail_from_address);
Setting::put('mail_from_name', $this->mail_from_name);
// Only overwrite the stored password when a new one was typed; otherwise keep
// the existing (encrypted) value untouched. Store encrypted at rest.
if ($this->mail_password !== '') {
Setting::put('mail_password', Crypt::encryptString($this->mail_password));
$this->passwordSet = true;
}
$this->reset('mail_password');
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.configure',
'target' => $this->mail_host,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('mail.notify_saved'));
}
/**
* Fully clear the SMTP configuration removes every mail_* setting (incl. the encrypted
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
*/
public function clearConfig(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
Setting::forget($key);
}
$this->mail_host = '';
$this->mail_port = 587;
$this->mail_username = '';
$this->mail_password = '';
$this->mail_encryption = 'tls';
$this->mail_from_address = '';
$this->mail_from_name = '';
$this->passwordSet = false;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.reset',
'target' => '—',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('mail.notify_reset'));
}
/**
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
* from the saved settings. Never targets anyone else; never leaks the password any
* transport error is surfaced as a message with the secret masked out.
*/
public function sendTest(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! $this->configured()) {
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
return;
}
// Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay.
// Per-user + auto-expiring: only ever limits the operator's own test button briefly.
$key = 'mail-test:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user
// Retarget guard: never test-send the STORED password against a host/username the operator
// just changed in the form but hasn't saved — that could leak the old credential to a new
// (possibly hostile) server. Require the password to be re-entered for a changed target.
if ($this->mail_password === ''
&& ($this->mail_host !== (string) Setting::uncached('mail_host', '')
|| $this->mail_username !== (string) Setting::uncached('mail_username', ''))) {
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.test_retarget_password_required')]), level: 'error');
return;
}
$to = Auth::user()->email;
try {
$this->applyMailerConfig();
Mail::raw(__('mail.test_body'), function ($message) use ($to) {
$message->to($to)->subject(__('mail.test_subject'));
});
$this->dispatch('notify', message: __('mail.notify_test_ok', ['email' => $to]));
} catch (Throwable $e) {
$this->dispatch('notify', message: __('mail.notify_test_failed', [
'error' => $this->maskSecret(Str::limit($e->getMessage(), 160)),
]));
}
}
/** Host + from-address present — the minimum to actually send. */
public function configured(): bool
{
return $this->mail_host !== '' && $this->mail_from_address !== '';
}
/**
* Apply the saved SMTP settings to runtime config for the duration of this request,
* so the test-send uses exactly what the operator configured (decrypting the stored
* password). Mirrors AppServiceProvider's runtime override.
*/
private function applyMailerConfig(): void
{
// Prefer a FRESHLY typed password (test-before-save); fall back to the stored one only when
// the field is blank — and the sendTest() retarget guard has already ensured that fallback
// is used only when host+username are unchanged from what the stored password was set for.
$password = '';
if ($this->mail_password !== '') {
$password = $this->mail_password;
} else {
$stored = Setting::uncached('mail_password');
if ($stored !== null && $stored !== '') {
try {
$password = Crypt::decryptString($stored);
} catch (Throwable) {
$password = '';
}
}
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $this->mail_host,
'mail.mailers.smtp.port' => $this->mail_port,
'mail.mailers.smtp.username' => $this->mail_username ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $this->mail_encryption === 'none' ? null : $this->mail_encryption,
'mail.from.address' => $this->mail_from_address,
'mail.from.name' => $this->mail_from_name,
]);
// Drop any mailer Laravel already resolved this request so the new config takes effect.
Mail::purge('smtp');
}
/** Strip the stored SMTP password out of any string before it is shown/logged. */
private function maskSecret(string $text): string
{
$stored = Setting::get('mail_password');
if ($stored === null || $stored === '') {
return $text;
}
try {
$plain = Crypt::decryptString($stored);
} catch (Throwable) {
return $text;
}
return $plain !== '' ? str_replace($plain, '****', $text) : $text;
}
public function render()
{
return view('livewire.settings.email');
}
}

View File

@ -2,104 +2,28 @@
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Einstellungen — Clusev')]
class Index extends Component
{
#[Url]
public string $tab = 'profile';
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public bool $openRecoveryModal = false;
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: 'Profil gespeichert.');
}
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(10)],
]);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: 'Passwort geändert.');
}
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => '2FA deaktivieren',
'body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.',
'confirmLabel' => 'Deaktivieren',
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'event' => 'twoFactorDisabled',
'notify' => '2FA deaktiviert.',
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(): void
{
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
if (session('open_recovery_modal')) {
$this->tab = 'security';
$this->openRecoveryModal = true;
}
}
public function render()
{
return view('livewire.settings.index', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
]);
return view('livewire.settings.index')->title(__('settings.title'));
}
}

View File

@ -0,0 +1,170 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\Setting;
use App\Rules\ValidIpOrCidr;
use App\Services\BruteforceGuard;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\On;
use Livewire\Component;
class LoginProtection extends Component
{
public bool $enabled = true;
public int $maxretry = 10;
public int $findtime = 10;
public int $bantime = 60;
public string $whitelist = '';
public function mount(BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->enabled = $guard->enabled();
$this->maxretry = $guard->maxretry();
$this->findtime = $guard->findtime();
$this->bantime = $guard->bantime();
$this->whitelist = implode("\n", $guard->whitelist());
}
public function save(BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate([
'maxretry' => ['required', 'integer', 'min:1', 'max:1000'],
'findtime' => ['required', 'integer', 'min:1', 'max:1440'],
'bantime' => ['required', 'integer', 'min:1', 'max:43200'],
'whitelist' => ['nullable', 'string'],
]);
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
foreach ($lines as $line) {
if (Validator::make(['v' => $line], ['v' => [new ValidIpOrCidr]])->fails()) {
$this->addError('whitelist', __('settings.lp_whitelist_invalid', ['value' => $line]));
return;
}
}
Setting::put('bruteforce_enabled', $this->enabled ? '1' : '0');
Setting::put('bruteforce_maxretry', (string) $this->maxretry);
Setting::put('bruteforce_findtime', (string) $this->findtime);
Setting::put('bruteforce_bantime', (string) $this->bantime);
Setting::put('bruteforce_whitelist', implode("\n", $lines));
// Widening the whitelist immediately clears any now-exempt active bans.
foreach (BannedIp::active()->get() as $ban) {
if ($guard->isExempt($ban->ip)) {
$guard->unban($ban->ip);
}
}
$this->audit('auth.protection_updated', null);
$this->dispatch('notify', message: __('settings.lp_saved'));
}
public function whitelistMyIp(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$ip = (string) request()->ip();
$lines = array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $this->whitelist) ?: [])));
if (! in_array($ip, $lines, true)) {
$lines[] = $ip;
}
$this->whitelist = implode("\n", $lines);
}
public function confirmUnban(string $ip): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_heading'),
'body' => __('settings.lp_unban_body', ['ip' => $ip]),
'confirmLabel' => __('settings.lp_unban'),
'danger' => false,
'icon' => 'shield',
'notify' => __('settings.lp_unban_notify'),
'token' => ConfirmToken::issue('banCleared', ['ip' => $ip], auditTarget: $ip),
],
);
}
#[On('banCleared')]
public function unban(string $confirmToken, BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'banCleared');
} catch (InvalidConfirmToken) {
return;
}
$ip = (string) $payload['params']['ip'];
$guard->unban($ip);
$this->audit('auth.ip_unbanned', $ip);
}
public function confirmUnbanAll(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.lp_unban_all_heading'),
'body' => __('settings.lp_unban_all_body'),
'confirmLabel' => __('settings.lp_unban_all'),
'danger' => true,
'icon' => 'shield',
'notify' => __('settings.lp_unban_all_notify'),
'token' => ConfirmToken::issue('bansClearedAll'),
],
);
}
#[On('bansClearedAll')]
public function unbanAll(string $confirmToken, BruteforceGuard $guard): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
ConfirmToken::consume($confirmToken, 'bansClearedAll');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$guard->unbanAll();
$this->audit('auth.ip_unbanned', 'all');
}
private function audit(string $action, ?string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(BruteforceGuard $guard)
{
$ip = (string) request()->ip();
return view('livewire.settings.login-protection', [
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(),
'currentIp' => $ip,
'currentIpExempt' => $guard->isExempt($ip),
// Read-only status of the env-driven honeypot deception layer (no toggle here).
'honeypotEnabled' => (bool) config('clusev.honeypot.enabled'),
]);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Profile extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function updatePassword(): void
{
// Throttle the current-password re-auth (hijacked-session brute-force protection).
// User-id keyed + auto-expiring: only ever slows the user's own session briefly.
$key = 'reauth:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'current_password' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
try {
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(6)],
]);
} catch (ValidationException $e) {
if (array_key_exists('current_password', $e->errors())) {
RateLimiter::hit($key, 60);
}
throw $e;
}
RateLimiter::clear($key);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: __('settings.password_changed'));
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.profile');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Settings;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class Security extends Component
{
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.disable_2fa_heading'),
'body' => __('settings.disable_2fa_body'),
'confirmLabel' => __('common.disable'),
'danger' => true,
'icon' => 'shield',
'notify' => __('settings.disable_2fa_notify'),
'token' => ConfirmToken::issue(
'twoFactorDisabled',
[],
'two_factor.disable',
Auth::user()?->email,
),
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(string $confirmToken): void
{
try {
ConfirmToken::consume($confirmToken, 'twoFactorDisabled');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
// Remove the TOTP factor only — security keys are managed on their own card. If no
// factor remains afterwards, the backup codes are dropped too.
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
Auth::user()->resetIfNoFactor();
}
public function render()
{
return view('livewire.settings.security', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
'hasTotp' => Auth::user()->hasTotp(),
]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Livewire\Settings;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class Sessions extends Component
{
/**
* Open the R5 confirm for "sign out other devices". The current session is
* kept; only the other rows are deleted on confirm. The modal writes the
* audit row (session.logout_others) and re-dispatches the apply event.
*/
public function confirmLogoutOthers(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('sessions.logout_others'),
'body' => __('sessions.logout_others_body'),
'confirmLabel' => __('sessions.logout_others'),
'danger' => true,
'icon' => 'logout',
'notify' => __('sessions.logout_others_notify'),
'token' => ConfirmToken::issue(
'sessionsLogoutOthers',
[],
'session.logout_others',
Auth::user()?->email,
),
],
);
}
#[On('sessionsLogoutOthers')]
public function logoutOthers(string $confirmToken, SessionService $sessions): void
{
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutOthers');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$sessions->logoutOtherDevices(Auth::user());
// List re-renders on the next round trip; no explicit refresh needed.
}
/**
* Open the R5 confirm for the destructive GLOBAL logout. On confirm every
* session is wiped and every remember_token rotated including this
* operator's, so the apply handler redirects to login.
*/
public function confirmLogoutAll(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('sessions.logout_all'),
'body' => __('sessions.logout_all_body'),
'confirmLabel' => __('sessions.logout_all'),
'danger' => true,
'icon' => 'power',
// Defer the toast: this session is about to be destroyed and the
// user redirected to login, so a toast would never be seen.
'notify' => '',
'token' => ConfirmToken::issue(
'sessionsLogoutAll',
[],
'session.logout_all',
Auth::user()?->email,
),
],
);
}
#[On('sessionsLogoutAll')]
public function logoutAll(string $confirmToken, SessionService $sessions)
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
// Truncates all sessions + rotates every remember_token. This logs the
// operator out too, so push them to login (AuthenticateSession would
// otherwise bounce the very next request).
$sessions->logoutEveryone();
Auth::logout();
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.settings.sessions', [
'sessions' => app(SessionService::class)->forUser(Auth::user()),
]);
}
}

View File

@ -0,0 +1,320 @@
<?php
namespace App\Livewire\Settings;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Multi-user account management. Accounts now carry an RBAC role (admin > operator >
* viewer); the mutating actions here are gated behind the `manage-users` ability (admin
* only). Destructive actions (remove, force-logout) and role changes go through the R5
* confirm modal, which writes one AuditEvent and re-dispatches the apply event back here
* (mirrors Settings\Security / Settings\Sessions).
*
* The last-admin invariant is enforced everywhere: the sole remaining admin can neither
* be removed nor demoted, so the panel can never be stranded with zero administrators.
*
* The current operator is always recorded as the actor on each audit row.
*/
class Users extends Component
{
/** Open the CreateUser modal; it dispatches `usersChanged` on success. */
public function create(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal', component: 'modals.create-user');
}
/**
* Destructive (R5): open the confirm modal for removing an account. Guards
* (self / last account) are enforced here AND again in the apply handler, so
* a stale event can never slip past after the modal is open.
*/
public function confirmRemove(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! $this->canRemove($user)) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.remove_heading'),
'body' => __('accounts.remove_body'),
'confirmLabel' => __('accounts.remove'),
'danger' => true,
'icon' => 'trash',
// Defer the toast: the apply handler reports the real outcome.
'notify' => '',
'token' => ConfirmToken::issue(
'userRemoved',
['userId' => $userId],
auditTarget: $user->email,
),
],
);
}
#[On('userRemoved')]
public function remove(string $confirmToken, SessionService $sessions): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userRemoved');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$self = Auth::id();
// Atomic: lock the user rows + recount inside the transaction so two concurrent
// deletes can't both pass the "more than one account" check and strand the
// install with zero users (a lock-out). The last account is never removable.
// Sentinel: null = silent no-op (self / last account / gone); 'last_admin' =
// refused because it would strand zero admins → surface the guard toast.
$email = DB::transaction(function () use ($userId, $self, $sessions): ?string {
if (User::lockForUpdate()->count() <= 1) {
return null;
}
$user = User::find($userId);
if (! $user || $user->getKey() === $self) {
return null;
}
// Last-admin invariant: removing the sole administrator would leave the panel
// with zero admins (no one can manage users again). Lock + recount admins in
// the same transaction so concurrent removes can't both slip past the check.
if ($user->role === Role::Admin
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
return 'last_admin';
}
$email = $user->email;
// Kill every session + rotate the remember_token BEFORE deleting, so a
// stolen remember-me cookie can never resurrect the account between the
// session wipe and the row delete.
$sessions->logoutUserEverywhere($user);
$user->delete();
return $email;
});
if ($email === null) {
return;
}
if ($email === 'last_admin') {
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
return;
}
$this->audit('user.delete', $email);
$this->dispatch('notify', message: __('accounts.remove_notify'));
}
/**
* Destructive (R5): open the confirm modal for signing another account out
* of every device. Never offered for self (self uses Settings Sessions).
*/
public function confirmLogout(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || $user->is(Auth::user())) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.logout_heading'),
'body' => __('accounts.logout_body'),
'confirmLabel' => __('accounts.logout'),
'danger' => true,
'icon' => 'logout',
'notify' => '',
'token' => ConfirmToken::issue(
'userLoggedOut',
['userId' => $userId],
auditTarget: $user->email,
),
],
);
}
#[On('userLoggedOut')]
public function logout(string $confirmToken, SessionService $sessions): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userLoggedOut');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$user = User::find($userId);
if (! $user || $user->is(Auth::user())) {
return;
}
$sessions->logoutUserEverywhere($user);
$this->audit('user.logout', $user->email);
$this->dispatch('notify', message: __('accounts.logout_notify'));
}
/**
* Sensitive (R5): open the confirm modal for changing another account's role. The
* new role must be a valid Role case; the apply handler re-checks the last-admin
* invariant so a stale event can never demote the sole admin after the modal opens.
*/
public function confirmRoleChange(int $userId, string $role): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! in_array($role, array_column(Role::cases(), 'value'), true)) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('accounts.role_change_confirm_heading'),
'body' => __('accounts.role_change_confirm_body', [
'name' => $user->name,
'role' => Role::from($role)->label(),
]),
'confirmLabel' => __('accounts.role_change_confirm'),
'danger' => false,
'icon' => 'shield',
// Defer the toast: the apply handler reports the real outcome.
'notify' => '',
'token' => ConfirmToken::issue(
'userRoleChanged',
['userId' => $userId, 'role' => $role],
auditTarget: $user->email,
),
],
);
}
#[On('userRoleChanged')]
public function applyRoleChange(string $confirmToken): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'userRoleChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$userId = $payload['params']['userId'];
$role = $payload['params']['role'];
// Atomic: lock the target + recount admins inside the transaction so two concurrent
// demotions can't both pass the last-admin check and strand zero administrators.
// Sentinel: null = silent no-op (gone / same role); 'last_admin' = refused because
// it would demote the sole admin → surface the guard toast.
$result = DB::transaction(function () use ($userId, $role): ?string {
$user = User::whereKey($userId)->lockForUpdate()->first();
if (! $user) {
return null;
}
$newRole = Role::from($role);
// Last-admin invariant: never demote the sole administrator away from admin.
if ($user->role === Role::Admin
&& $newRole !== Role::Admin
&& User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) {
return 'last_admin';
}
$user->role = $newRole;
$user->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name,
'action' => 'user.role_change',
'target' => $user->email,
'ip' => request()->ip(),
'meta' => ['to' => $role],
]);
return $user->email;
});
if ($result === null) {
return;
}
if ($result === 'last_admin') {
$this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error');
return;
}
$this->dispatch('usersChanged');
$this->dispatch('notify', message: __('accounts.role_changed_toast'));
}
/** Reload the list after the CreateUser modal adds an account. */
#[On('usersChanged')]
public function refreshList(): void
{
// The list is re-fetched in render(); this handler just forces the round trip.
}
/**
* A user may be offered the remove control unless it is the current operator or the
* last remaining account (no lock-out). This is only the UI gate; the removal itself
* is restricted to admins (`manage-users`) and the apply handler additionally refuses
* to strand zero administrators (last-admin invariant).
*/
private function canRemove(User $user): bool
{
return ! $user->is(Auth::user()) && User::count() > 1;
}
private function audit(string $action, ?string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.users', [
'users' => User::query()->orderBy('name')->get(),
'currentId' => Auth::id(),
]);
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use App\Services\WebauthnService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
class WebauthnKeys extends Component
{
#[Validate('required|string|max:60')]
public string $newName = '';
/** JSON creation options for navigator.credentials.create (the JS posts the result to register). */
public function options(WebauthnService $webauthn): array
{
abort_unless($webauthn->available(), 404);
// Validate the label BEFORE the browser ceremony, so an invalid name never
// leaves an orphaned credential on the authenticator.
$this->validate();
return $webauthn->registrationOptions(Auth::user());
}
public function register(array $attestation, WebauthnService $webauthn): void
{
// WebAuthn must be available (domain + HTTPS). No existing-factor guard — a key may
// be the FIRST/only factor.
abort_unless($webauthn->available(), 404);
$this->validate();
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
$this->reset('newName');
// First factor enrolled and no codes yet → generate + show the recovery modal.
if (! Auth::user()->hasRecoveryCodes()) {
Auth::user()->replaceRecoveryCodes();
// Server-set one-time flag so the opening modal reveals the fresh set once.
session()->put('2fa.codes_fresh', now()->timestamp);
// One-time download grant (a timestamp) — the download route requires + consumes
// it within a short window, so the fresh set can be downloaded once and a later
// direct hit 403s.
session()->put('2fa.download_grant', now()->timestamp);
$this->dispatch('openModal', component: 'modals.recovery-codes');
}
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'webauthn.register',
'target' => $cred->name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('auth.webauthn_added'));
}
/** Destructive (R5): open the confirm modal, which re-dispatches `webauthnKeyRemoved`. */
public function confirmRemove(int $id): void
{
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
if (! $cred) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('auth.webauthn_title'),
'body' => __('auth.webauthn_remove_confirm'),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
'notify' => '',
'token' => ConfirmToken::issue(
'webauthnKeyRemoved',
['id' => $id],
),
],
);
}
#[On('webauthnKeyRemoved')]
public function remove(string $confirmToken, WebauthnService $webauthn): void
{
abort_unless($webauthn->available(), 404);
try {
$payload = ConfirmToken::consume($confirmToken, 'webauthnKeyRemoved');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$id = $payload['params']['id'];
$cred = Auth::user()->webauthnCredentials()->whereKey($id)->first();
if ($cred) {
$name = $cred->name;
$cred->delete();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name ?? 'system',
'action' => 'webauthn.remove',
'target' => $name,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('auth.webauthn_removed'));
Auth::user()->resetIfNoFactor();
}
}
public function render()
{
return view('livewire.settings.webauthn-keys', [
'available' => app(WebauthnService::class)->available(),
'keys' => Auth::user()->webauthnCredentials()->latest()->get(),
]);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Shell;
use App\Models\Server;
use Livewire\Component;
/**
* Topbar "X / Y online" pill. A tiny polled Livewire island so the fleet-online count reflects a
* server going up or down live (like the dashboard), instead of only on a page navigate/refresh.
*/
class FleetStatus extends Component
{
public function render()
{
return view('livewire.shell.fleet-status', [
'online' => Server::where('status', 'online')->count(),
'total' => Server::count(),
]);
}
}

View File

@ -2,80 +2,253 @@
namespace App\Livewire\System;
use App\Models\Setting;
use App\Models\AuditEvent;
use App\Services\DeploymentService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* System settings dashboard-configurable Domain + TLS and the release channel.
* System settings dashboard-configurable Domain + TLS.
*
* Domain & TLS: READ-ONLY status. The panel domain is an install-time decision
* (APP_DOMAIN) so it stays consistent with URL/WebSocket/cookie security; Caddy then
* issues, renews and HTTP→HTTPS-redirects fully automatically. Nothing to apply or
* reload here to change the domain, re-run install.sh.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
* mid-session that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
*/
#[Layout('layouts.app')]
#[Title('System — Clusev')]
class Index extends Component
{
/** Valid user-facing release channels with their German descriptions. */
public const CHANNELS = [
'stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.',
'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
/** Install-time panel domain (read-only display). */
/** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */
public string $domain = '';
public string $channel = 'stable';
/** The domain form field (FQDN, or empty to clear the override). */
public string $domainInput = '';
/** True after a save — shows the "restart the stack to apply" notice. */
public bool $restartPending = false;
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $tlsMode = 'caddy';
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
// (configured) domain. They differ when a saved change awaits a restart.
$this->domain = (string) ($deployment->domain() ?? '');
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable';
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
/** @return array<string, array<int, string>> */
private function domainRules(): array
{
if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) {
// Empty clears the override (revert to install-time APP_DOMAIN / bare IP).
return ['domainInput' => ['nullable', 'string', 'max:253', 'regex:'.DeploymentService::DOMAIN_REGEX]];
}
/** Validate, then confirm the domain change via the shared modal (audited there). */
public function confirmDomain(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate($this->domainRules(), [
'domainInput.regex' => __('system.domain_invalid'),
'domainInput.max' => __('system.domain_invalid'),
]);
$new = strtolower(trim($this->domainInput));
if ($new === (string) ($deployment->configuredDomain() ?? '')) {
return; // no change vs the already-configured (pending) domain
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_domain_heading'),
'body' => $new !== ''
? __('system.change_domain_body', ['domain' => $new])
: __('system.clear_domain_body'),
'confirmLabel' => __('system.change_domain_confirm'),
'danger' => true,
'icon' => 'globe',
'notify' => '', // defer: we surface the restart notice instead of a toast
'token' => ConfirmToken::issue(
'domainChanged',
['domain' => $new],
'system.settings_updated',
'panel_domain='.($new !== '' ? $new : '(cleared)'),
),
],
);
}
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$domain = strtolower(trim($payload['params']['domain']));
// Defensive re-validation (the value round-trips through the modal event).
if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) {
return;
}
$pending = $deployment->setDomain($domain);
// The active (served) domain stays frozen until restart; only the pending changes.
$this->domainInput = (string) ($pending ?? '');
$this->restartPending = $deployment->restartPending();
$this->dispatch('notify', message: __('system.domain_saved_notify'));
}
/**
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
* The container never touches the Docker socket it only requests via a marker file
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
* (a button, not auto-fired on save) so the operator controls the timing.
*/
public function restartNow(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
// Surface a failed sentinel write instead of a "restarting" state that never resolves.
if (! $deployment->requestRestart()) {
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
return;
}
$this->restartRequested = true;
$this->dispatch('notify', message: __('system.restart_running'));
}
/**
* Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS
* pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user
* throttled (5 / 10 min, auto-expiring never a control-plane lockout) so it can't hammer
* ACME, and audited. Only meaningful in caddy mode with an active domain.
*/
public function requestCertificate(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$domain = $deployment->domain();
if ($domain === null || $deployment->externalTls()) {
$this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error');
return;
}
$key = 'cert-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600);
$result = $deployment->requestCertificate($domain);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'tls.cert_request',
'target' => $domain.' → '.$result['status'],
'ip' => request()->ip(),
]);
[$message, $level] = match ($result['status']) {
'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'],
'dns_mismatch' => [__('system.cert_dns_mismatch', [
'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—',
'server' => $result['serverIp'] ?? '?',
]), 'error'],
default => [__('system.cert_failed'), 'error'],
};
$this->dispatch('notify', message: $message, level: $level);
}
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Release-Kanal wechseln',
'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.',
'confirmLabel' => 'Wechseln',
'danger' => false,
'icon' => 'git-branch',
'auditAction' => 'system.settings_updated',
'auditTarget' => 'release_channel='.$channel,
'event' => 'channelChanged',
'params' => ['channel' => $channel],
'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.',
'heading' => __('system.change_tls_heading'),
'body' => $mode === 'external'
? __('system.change_tls_body_external')
: __('system.change_tls_body_caddy'),
'confirmLabel' => __('system.change_tls_confirm'),
'danger' => true,
'icon' => 'shield',
'notify' => '',
'token' => ConfirmToken::issue(
'tlsModeChanged',
['mode' => $mode],
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $channel): void
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
{
if (! array_key_exists($channel, self::CHANNELS)) {
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$mode = $payload['params']['mode'];
if (! in_array($mode, self::TLS_MODES, true)) {
return;
}
Setting::put('release_channel', $channel);
$this->channel = $channel;
$deployment->setTlsMode($mode);
$this->tlsMode = $mode;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.settings_updated',
'target' => 'tls_mode='.$mode,
'ip' => request()->ip(),
]);
$this->restartPending = true;
$this->dispatch('notify', message: __('system.tls_saved_notify'));
}
public function render(DeploymentService $deployment)
@ -83,7 +256,10 @@ class Index extends Component
return view('livewire.system.index', [
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'channels' => self::CHANNELS,
]);
'isOverridden' => $deployment->domainIsOverridden(),
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
'certStatus' => $deployment->certStatus(),
])->title(__('system.title'));
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Livewire\Terminal;
use App\Models\HostCredential;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Host terminal a real SSH login into the CLUSEV HOST machine, admin-only (manage-fleet). Per-SERVER
* shells live on the server-details page (the "Terminal" tab / App\Livewire\Servers\ServerTerminal).
* On connect it mints a single-use token the xterm.js island uses to open a WebSocket to the terminal
* sidecar; this component never touches the SSH connection or credentials.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public function mount(): void
{
// The host shell is a real login into the Clusev machine — the most dangerous target.
abort_unless(Auth::user()?->can('manage-fleet'), 403);
}
public function title(): string
{
return __('terminal.title');
}
/** Mint a single-use token for the host shell and hand it to the xterm island via an event. */
public function open(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
// With no host login configured yet, open the setup form instead of minting a dead session.
if (HostCredential::current() === null) {
$this->configureHost();
return;
}
$token = Str::random(48);
TerminalSession::create([
'token' => $token,
'user_id' => Auth::id(),
'kind' => 'host',
'server_id' => null,
'expires_at' => now()->addSeconds(60),
'created_at' => now(),
]);
$this->dispatch('terminal-open', token: $token, title: __('terminal.host_label'));
}
/** Open the modal that stores the Clusev host SSH login. */
public function configureHost(): void
{
abort_unless(Auth::user()?->can('manage-fleet'), 403);
$this->dispatch('openModal', component: 'modals.host-shell');
}
/** The host-login modal saved/removed — re-render so the tile reflects the new state. */
#[On('hostShellSaved')]
public function hostShellSaved(): void
{
// No state to set; the empty handler simply triggers a re-render.
}
public function render()
{
$host = HostCredential::current();
return view('livewire.terminal.index', [
'hostConfigured' => $host !== null,
'hostTarget' => $host?->username,
])->title($this->title());
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace App\Livewire\Threats;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* THREATS dashboard the operator-facing frontend for the honeypot deception layer. Surfaces the
* hostile-probe feed (security.honeypot_hit / security.honeytoken_used / auth.ip_banned audit
* events), KPI counters, and the active IP bans with an unban control. Read-only intelligence plus
* one mutation (unban); the honeypot itself is env-driven and has no toggle here.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithPagination;
/** Threat entries per page — page is deep-linked via ?page= (Livewire WithPagination). */
private const PER_PAGE = 25;
/** The honeypot / ban action codes that make up the threat feed. */
private const FEED_ACTIONS = [
'security.honeypot_hit',
'security.honeypot_login',
'security.honeytoken_used',
'auth.ip_banned',
];
/** Freitext-Filter über IP / Ziel des Bedrohungs-Ereignisses. */
#[Url]
public string $q = '';
public function mount(): void
{
// Defense-in-depth: the route already carries can:manage-panel, but a persisted Livewire
// component could be re-hydrated after a role change — re-check on mount.
abort_unless(Auth::user()?->can('manage-panel'), 403);
}
/** A changed search must restart at page 1 — never land on an out-of-range page. */
public function updatedQ(): void
{
$this->resetPage();
}
/**
* Honeypot / ban events, newest first, optionally filtered. The filter runs in SQL (so it spans
* the whole history, not just one page) and matches the source IP and the probed target path.
* Mirrors Audit\Index::eventsQuery() but scoped to the honeypot action set.
*/
protected function eventsQuery(): Builder
{
$query = AuditEvent::query()->whereIn('action', self::FEED_ACTIONS)->latest();
$needle = trim($this->q);
if ($needle === '') {
return $query;
}
$like = '%'.mb_strtolower($needle).'%';
return $query->where(function (Builder $w) use ($like): void {
$w->whereRaw('LOWER(ip) LIKE ?', [$like])
->orWhereRaw('LOWER(target) LIKE ?', [$like]);
});
}
/** Currently active bans (not yet expired), newest first — a short list for the ban panel. */
protected function bannedIps(): Collection
{
return BannedIp::active()->latest('banned_until')->limit(50)->get();
}
/**
* Page numbers to render, with "" gaps for long histories (1 4 5 6 20).
*
* @return array<int, int|string>
*/
protected function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, max(1, $total));
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
/**
* Lift an active ban. Reuses the exact unban path + audit code + toast convention from
* Settings\LoginProtection so the two management surfaces stay consistent.
*/
public function unban(string $ip, BruteforceGuard $guard): void
{
abort_unless(Auth::user()?->can('manage-panel'), 403);
$guard->unban($ip);
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'auth.ip_unbanned',
'target' => $ip,
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('threats.unbanned_toast', ['ip' => $ip]));
}
public function render()
{
/** @var LengthAwarePaginator $events */
$events = $this->eventsQuery()->paginate(self::PER_PAGE);
// Most active source across all honeypot/token events (null when there is no traffic yet).
$topIp = AuditEvent::query()
->whereIn('action', ['security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used'])
->whereNotNull('ip')
->selectRaw('ip, COUNT(*) as hits')
->groupBy('ip')
->orderByDesc('hits')
->limit(1)
->value('ip');
return view('livewire.threats.index', [
'events' => $events,
'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()),
'bannedIps' => $this->bannedIps(),
'probes_24h' => AuditEvent::query()
->where('action', 'security.honeypot_hit')
->where('created_at', '>=', now()->subDay())
->count(),
// POST fake-login attempts in the last 24h — the "someone tried to log in" signal.
'login_attempts_24h' => AuditEvent::query()
->where('action', 'security.honeypot_login')
->where('created_at', '>=', now()->subDay())
->count(),
'banned_total' => BannedIp::active()->count(),
'honeytoken_trips' => AuditEvent::query()->where('action', 'security.honeytoken_used')->count(),
'top_ip' => $topIp,
])->title(__('threats.title'));
}
}

View File

@ -2,25 +2,48 @@
namespace App\Livewire\Versions;
use App\Models\Setting;
use App\Models\AuditEvent;
use App\Services\DeploymentService;
use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the installed version
* comes from config, the latest published release is the newest git TAG read from
* .git at runtime, and the release history is the project's own CHANGELOG.md parsed
* BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no
* fake release server the update check honestly compares the installed version
* against the newest tag in the configured channel.
* Version & releases page. Everything shown here is real: the installed version comes from
* config, the latest published release is the newest git TAG in the channel read from the
* project's public Git host over its read-only tag API (the production image ships no .git, so
* a local read would always be empty; local .git is only a dev fallback) and the release
* history is the project's own CHANGELOG.md parsed BY VERSION. There is no in-app updater
* (Clusev ships as a Docker image; updating is a host-side image pull + migrate, see the page);
* the check honestly compares the installed version against the newest tag in the channel.
*/
#[Layout('layouts.app')]
#[Title('Version — Clusev')]
class Index extends Component
{
/** Only these channels are ever offered to users — there is no 'dev' channel. */
private const CHANNELS = ['stable', 'beta'];
/**
* Cache (Redis in prod) marker for an in-flight update: holds the version that was running
* when the update was requested. Persisted in Redis NOT the sentinel file (which the host
* watcher consumes before update.sh runs) so the "update läuft" state survives BOTH a page
* reload and the app-container rebuild. Cleared on completion/timeout; auto-expires as a
* backstop so a failed update can never wedge the banner. Completion = running version > base.
*/
private const UPDATE_FLAG = 'clusev:update-in-progress';
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
private const CHANGELOG_PER_PAGE = 8;
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail.
* Synced to ?series= so a series is deep-linkable / survives reload + back-button. */
#[Url(as: 'series')]
public string $series = '';
/** 1-based page within the selected series' release list. Synced to ?page= (omitted while 1). */
#[Url(as: 'page')]
public int $changelogPage = 1;
public ?string $lastChecked = null;
@ -29,6 +52,101 @@ class Index extends Component
/** 'current' | 'update' | null — drives the banner tone. */
public ?string $updateState = null;
/** Set once an update has been requested this session — the stack is rebuilding. */
public bool $updateRequested = false;
/** Running version captured WHEN the update was requested — completion = version moved past it. */
public ?string $updateBaseVersion = null;
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
public int $updatePolls = 0;
/**
* On load, in order:
* 1. If an update is in flight (Redis marker): either announce it just finished (running
* version now past the marked base success toast + clear) or resume the "läuft" state +
* poll. This is what makes a reload mid-update show the right thing (the marker lives in
* Redis, surviving both the reload and the app rebuild).
* 2. Otherwise surface an available update straight from the cached check (no network), so the
* button shows immediately when the badge already knows a newer version no re-click.
*/
public function mount(): void
{
$installed = (string) config('clusev.version');
$marker = Cache::get(self::UPDATE_FLAG);
if (is_array($marker) && isset($marker['base'])) {
if (version_compare(ltrim($installed, 'vV'), ltrim((string) $marker['base'], 'vV'), '>')) {
// Finished while the operator was away (or reloaded) — announce + clear.
Cache::forget(self::UPDATE_FLAG);
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $installed]));
return;
}
// Still running — resume the notice + polling.
$this->updateRequested = true;
$this->updateBaseVersion = (string) $marker['base'];
return;
}
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
if ($latest !== null && $this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
}
}
/**
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
* detected purely from the running version moving PAST the value captured at request time
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
*/
public function pollUpdate(): void
{
if (! $this->updateRequested) {
return;
}
$this->updatePolls++;
$current = (string) config('clusev.version');
$this->updateBaseVersion ??= $current;
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
return;
}
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
Cache::forget(self::UPDATE_FLAG);
$this->updateRequested = false;
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
}
}
/**
* Auto-check on page load (wire:init) so an available update + button appear without the
* operator clicking "check" first. No-op while an update is running, so it never clobbers the
* "läuft"/poll state. Async (wire:init), so it never blocks the initial render.
*/
public function autoCheck(): void
{
if ($this->updateRequested) {
return;
}
$this->checkUpdates();
}
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
@ -39,34 +157,133 @@ class Index extends Component
$channel = $this->channel();
$installed = (string) config('clusev.version');
$latest = $this->latestTag();
// Force a fresh remote lookup — this is the explicit "check for updates" action.
$latest = $this->resolveLatestTag($channel, forceRemote: true);
if ($latest === null) {
$this->updateState = 'current';
$this->updateStatus = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
} elseif ($this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
} else {
$this->updateState = 'current';
$this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
}
}
/**
* Operator-triggered update: write the update sentinel a host watcher reacts to by running
* update.sh (git pull + idempotent re-install). Re-checks that a newer release than the
* installed one is actually published in the channel (never fires blindly), is per-user
* throttled (auto-expiring never a control-plane lockout) and audited. The container never
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
* say so and do not await a result.
*/
public function requestUpdate(DeploymentService $deployment): mixed
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$channel = $this->channel();
$installed = (string) config('clusev.version');
$latest = $this->resolveLatestTag($channel, forceRemote: true);
// Reflect the freshly-checked state, then refuse if there is nothing newer to apply.
$this->lastChecked = now()->format('H:i');
if ($latest === null || ! $this->isNewer($latest, $installed)) {
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
return null;
}
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
$key = 'update-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return null;
}
RateLimiter::hit($key, 600);
// Write the sentinel; if it can't be written (e.g. the bind-mounted dir isn't writable by
// the app user), surface that instead of a "running" state that would never resolve.
if (! $deployment->requestUpdate()) {
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
return null;
}
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
// moves past this base. 15 min TTL > the poll timeout, so a reload can still resume.
Cache::put(self::UPDATE_FLAG, ['base' => $installed], now()->addMinutes(15));
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.update_request',
'target' => $installed.' → '.$latest.' ('.$channel.')',
'ip' => request()->ip(),
]);
$this->updateRequested = true;
$this->updateBaseVersion = $installed; // completion = the running version moves past this
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_started'));
// Redirect to the self-contained progress page BEFORE the container goes down.
// navigate:false forces a full browser navigation (the page must be Livewire-independent
// to survive the teardown — see resources/views/update-progress.blade.php).
$prev = url()->previous();
$parsedPrev = parse_url($prev, PHP_URL_PATH);
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
? $parsedPrev
: route('dashboard', absolute: false);
// Pass the pre-update version: the progress page finishes only when the running version
// moves past it (the old container answers /up throughout the build — see the page).
return $this->redirect(
route('update.progress', ['return' => $returnPath, 'from' => $installed]),
navigate: false,
);
}
/** Resolve the configured channel, clamped to the user-facing set. */
private function channel(): string
{
$channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable');
return in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
return app(ReleaseChecker::class)->channel();
}
/**
* Newest release tag from .git, without needing the git binary. Reads loose
* refs under refs/tags plus packed-refs, normalises a leading "v", and sorts
* by semantic version. Returns null when nothing is tagged yet.
* Newest release tag for the channel. Prefers the public Git host's tag API (the
* production image ships NO .git, so a local read would always be empty there), and
* falls back to local .git tags for dev. The remote result is cached so a page render
* never makes a network call only the explicit "check for updates" action does, by
* passing $forceRemote. Returns null when nothing usable is found anywhere.
*/
private function latestTag(): ?string
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
$checker = app(ReleaseChecker::class);
if ($forceRemote) {
$remote = $checker->refresh($channel);
if ($remote !== null) {
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
}
/**
* Newest release tag from local .git, without needing the git binary. Reads loose refs
* under refs/tags plus packed-refs. Empty in the production image (no .git) that is why
* the remote API is the primary source above.
*/
private function localLatestTag(string $channel): ?string
{
$root = base_path('.git');
$tags = [];
@ -86,22 +303,7 @@ class Index extends Component
}
}
// Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v".
$versions = [];
foreach (array_unique($tags) as $tag) {
$v = ltrim($tag, 'vV');
if (preg_match('/^\d+\.\d+\.\d+/', $v)) {
$versions[] = $v;
}
}
if ($versions === []) {
return null;
}
usort($versions, fn (string $a, string $b): int => version_compare($b, $a));
return $versions[0];
return app(ReleaseChecker::class)->newestVersion($tags, $channel);
}
/** Is the given tag a newer semantic version than the installed one? */
@ -110,9 +312,19 @@ class Index extends Component
return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>');
}
/** Resolve the current commit from .git without needing the git binary. */
/**
* Resolve the deployed commit + branch. In production this comes from config (install.sh
* baked it into .env from the host's .git the prod image ships none); in dev it falls back
* to a live .git read. Branch defaults to the channel only when nothing is known.
*/
private function build(): array
{
$sha = config('clusev.build.sha');
$branch = config('clusev.build.branch');
if ($sha || $branch) {
return ['sha' => $sha, 'branch' => $branch ?: $this->channel()];
}
$root = base_path('.git');
$head = @file_get_contents($root.'/HEAD');
@ -143,6 +355,106 @@ class Index extends Component
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
}
/** Switch the visible major.minor series and jump back to its first page. */
public function selectSeries(string $series): void
{
$this->series = $series;
$this->changelogPage = 1;
}
/** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
public function gotoChangelogPage(int $page): void
{
$this->changelogPage = max(1, $page);
}
/**
* Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
* keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
* if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
* show a compact series index + paginated detail instead of one endless list.
*
* @param array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}> $releases
* @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
*/
private function groupBySeries(array $releases): array
{
$buckets = [];
foreach ($releases as $rel) {
if ($rel['unreleased']) {
$key = 'unreleased';
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
// Prefix the series key with a non-numeric 'v' so the ?series= URL value is never a
// bare decimal — Livewire's #[Url] otherwise type-juggles "0.10" → 0.1 (the trailing
// zero is lost), silently switching the selected series on reload. The label strips it.
$key = 'v'.$m[1].'.'.$m[2];
} else {
$key = 'other';
}
$buckets[$key][] = $rel;
}
$series = [];
foreach ($buckets as $key => $rels) {
$dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
$series[] = [
'key' => $key,
'label' => match ($key) {
'unreleased' => __('versions.unreleased'),
'other' => __('versions.series_other'),
default => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
},
'unreleased' => $key === 'unreleased',
'count' => count($rels),
'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
'releases' => $rels,
];
}
// Unreleased first, then series by version descending, 'other' last.
usort($series, static function (array $a, array $b): int {
$rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
$ra = $rank($a);
$rb = $rank($b);
if ($ra !== $rb) {
return $ra <=> $rb;
}
return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
});
return $series;
}
/**
* Compact pagination window the full range up to 7 pages, otherwise first + last plus a
* 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
*
* @return array<int, int|string>
*/
private function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, $total);
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
/**
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
* version, optional date, and the changes grouped by Keep-a-Changelog
@ -158,11 +470,22 @@ class Index extends Component
return [];
}
return $this->parseChangelog((string) file_get_contents($path));
}
/**
* Parse a Keep-a-Changelog document into release nodes (shared by the local history and the
* remote "what's new" preview).
*
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
private function parseChangelog(string $content): array
{
$releases = [];
$current = null;
$group = null;
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
foreach (preg_split('/\R/', $content) as $line) {
// Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]"
if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) {
if ($current !== null) {
@ -215,19 +538,132 @@ class Index extends Component
return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== []));
}
/**
* The changelog entries the operator would GAIN by updating: the remote CHANGELOG.md at the
* latest tag, parsed and filtered to released versions newer than the installed one so the
* "what's new" can be read BEFORE applying the update. Empty when there is no update or the
* fetch fails (degrade gracefully; the update button is unaffected).
*
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
private function availableChangelog(string $installed, ?string $latestTag): array
{
if ($latestTag === null || ! $this->isNewer($latestTag, $installed)) {
return [];
}
// Tags are published as "vX.Y.Z" but $latestTag is the bare version — ref must carry the "v".
$content = app(ReleaseChecker::class)->fetchChangelog('v'.ltrim($latestTag, 'vV'));
if ($content === null) {
return [];
}
$inst = ltrim($installed, 'vV');
return array_values(array_filter(
$this->parseChangelog($content),
static fn (array $r): bool => ! $r['unreleased']
&& preg_match('/^\d+\.\d+\.\d+/', $r['version']) === 1
&& version_compare(ltrim($r['version'], 'vV'), $inst, '>'),
));
}
/**
* Links shown on the "Projekt" card. EVERY install lists the public open-core repo. The
* dev/release-control box ALSO lists its actual update source first (which may be the private
* upstream) staging and stable never do, so a non-dev server can never expose a private source
* regardless of how its CLUSEV_REPOSITORY is set. (The update SOURCE is still
* config('clusev.repository'); this only governs the displayed links.)
*
* release_controls=true is the dev-box invariant: it also gates the dev-only /release page + the
* host release bridge, so a staging/stable box never has it on and even if it did, that box's
* CLUSEV_REPOSITORY is the public repo anyway. The public link is built from the public slug
* ONLY, and a malformed slug (empty / slashed / a full URL) falls back to the canonical public
* repo never to a private host.
*
* @return list<array{url: string, host: string, path: string}>
*/
private function projectLinks(): array
{
$slug = trim((string) config('clusev.public_slug'), '/');
if (! preg_match('#^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$#', $slug)) {
$slug = 'clusev/clusev';
}
$urls = ['https://github.com/'.$slug];
// Dev box only: ALSO surface the actual update source (e.g. the private upstream), shown first.
// Require a real URL (a parseable host) so a misconfigured/garbage source is dropped rather
// than rendered as an empty link — the public repo still shows.
if (config('clusev.release_controls')) {
$source = (string) config('clusev.repository');
if (parse_url($source, PHP_URL_HOST) !== null && ! in_array($source, $urls, true)) {
array_unshift($urls, $source);
}
}
return array_map(static fn (string $u): array => [
'url' => $u,
'host' => (string) parse_url($u, PHP_URL_HOST),
'path' => trim((string) parse_url($u, PHP_URL_PATH), '/'),
], $urls);
}
public function render()
{
$releases = $this->releases();
$latestTag = $this->latestTag();
$seriesList = $this->groupBySeries($releases);
// Active series: keep the operator's choice while it still exists, else the newest.
$keys = array_column($seriesList, 'key');
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
$activeSeries = null;
foreach ($seriesList as $s) {
if ($s['key'] === $activeKey) {
$activeSeries = $s;
break;
}
}
// Paginate the active series' releases so a long history never becomes an endless scroll.
$activeReleases = $activeSeries['releases'] ?? [];
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
$page = min(max(1, $this->changelogPage), $totalPages);
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
// Mark the series that holds the running version (a small "installed" cue in the rail).
$installed = ltrim((string) config('clusev.version'), 'vV');
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? 'v'.$m[1].'.'.$m[2] : null;
// Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel());
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an
// update, before the next check). Never present yourself as behind a release you've
// already passed — hide it until a fresh check resolves the true latest.
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
$latestTag = null;
}
return view('livewire.versions.index', [
'version' => config('clusev.version'),
'channel' => $this->channel(),
'repository' => config('clusev.repository'),
'repositories' => $this->projectLinks(),
'license' => config('clusev.license'),
'build' => $this->build(),
'releases' => $releases,
'seriesList' => $seriesList,
'activeKey' => $activeKey,
'activeSeries' => $activeSeries,
'pagedReleases' => $pagedReleases,
'changelogPage' => $page,
'totalPages' => $totalPages,
'pageWindow' => $this->pageWindow($page, $totalPages),
'currentSeries' => $currentSeries,
'totalReleases' => count($releases),
'latestTag' => $latestTag,
]);
// What the operator would gain by updating — shown BEFORE applying (only when behind).
'availableUpdates' => $this->updateState === 'update'
? $this->availableChangelog((string) config('clusev.version'), $latestTag)
: [],
])->title(__('versions.title'));
}
}

View File

@ -0,0 +1,530 @@
<?php
namespace App\Livewire\Wireguard;
use App\Models\AuditEvent;
use App\Models\WgTrafficSample;
use App\Services\WgBridge;
use App\Services\WgStatus;
use App\Services\WgTraffic;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* WireGuard dashboard live status (P1) + traffic (P2) + peer management (P3). Reads the
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public const WINDOWS = [3600, 86400, 604800];
/** Peer-name charset, shared by every app-side validation site (the host re-validates too). */
private const PEER_NAME_RE = '/^[A-Za-z0-9._-]{1,64}$/';
public int $window = self::WINDOWS[0];
/** Active tab when configured: 'peers' (list + traffic) or 'server' (settings). Deep-linkable. */
#[Url]
public string $tab = 'peers';
public string $newPeer = '';
public string $newEndpoint = '';
public string $newPort = '';
public string $newSubnet = '';
public string $newDns = '';
// first-time setup form
public string $setupSubnet = '10.99.0.0/24';
public string $setupPort = '51820';
public string $setupEndpoint = '';
public string $setupPeer = 'client-1';
/** id of an in-flight write-request we are polling for. */
public ?string $pendingId = null;
public ?string $pendingAction = null;
/** Unix time the current request started polling — used to time out a non-responding host. */
public ?int $pendingSince = null;
/** Show-once add-peer result (config text + QR svg). Never persisted. */
public ?string $resultConfig = null;
public ?string $resultQr = null;
/** Peer name of the in-flight request, carried to the result so the download gets a meaningful filename. */
public ?string $pendingName = null;
public ?string $resultName = null;
/**
* Page guard (RBAC): WireGuard is a manage-network capability. The route carries a
* `can:manage-network` middleware for the initial GET, but a Livewire component update
* (POST /livewire/update) is NOT re-run through the route middleware so this mount()
* check is the real per-request guard for the page itself.
*/
public function mount(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
}
public function setWindow(int $seconds): void
{
$this->window = $this->clampWindow($seconds);
}
public function setTab(string $tab): void
{
$this->tab = in_array($tab, ['peers', 'server'], true) ? $tab : 'peers';
}
public function addPeer(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
'newPeer.regex' => __('wireguard.peer_name_invalid'),
'newPeer.required' => __('wireguard.peer_name_invalid'),
]);
if (! $this->throttle()) {
return;
}
$name = $this->newPeer;
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
$this->pendingAction = 'add-peer';
$this->pendingName = $name;
$this->newPeer = '';
$this->audit('wg.add-peer', $name);
}
public function setupWg(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate([
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
'setupPeer' => ['required', 'regex:'.self::PEER_NAME_RE],
], [
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
'setupPort.regex' => __('wireguard.port_invalid'),
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
]);
if (! $this->portInRange($this->setupPort)) {
$this->addError('setupPort', __('wireguard.port_invalid'));
return;
}
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('setup', [
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
]);
$this->pendingAction = 'setup';
$this->pendingName = $this->setupPeer;
$this->audit('wg.setup', $this->setupSubnet);
}
/** Opens the wire-elements/modal confirm dialog for peer removal. */
public function confirmRemovePeer(string $name): void
{
if (preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
$this->openConfirm('wgPeerRemoved', ['name' => $name],
__('wireguard.remove_confirm_title'), __('wireguard.remove_confirm_body'),
__('wireguard.remove'), danger: true, icon: 'trash');
}
/** Apply handler — called after the ConfirmAction modal confirms the removal. */
#[On('wgPeerRemoved')]
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
{
// Re-gate on consume: a token issued while the user still had rights must be
// refused if the role was demoted before it was confirmed (tokens are uid-bound).
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
} catch (InvalidConfirmToken) {
return;
}
$name = $payload['params']['name'] ?? '';
if ($name === '' || preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
$this->removePeer($bridge, $name);
}
public function removePeer(WgBridge $bridge, string $name): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
$this->pendingAction = 'remove-peer';
$this->audit('wg.remove-peer', $name);
}
public function setEndpoint(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
'newEndpoint.required' => __('wireguard.endpoint_invalid'),
]);
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]);
$this->pendingAction = 'set-endpoint';
$this->audit('wg.set-endpoint', $this->newEndpoint);
$this->newEndpoint = '';
}
/** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */
public function setDns(WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
$this->validate(['newDns' => ['required', 'regex:/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/']], [
'newDns.regex' => __('wireguard.dns_invalid'),
'newDns.required' => __('wireguard.dns_invalid'),
]);
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('set-dns', ['dns' => $this->newDns]);
$this->pendingAction = 'set-dns';
$this->audit('wg.set-dns', $this->newDns);
$this->newDns = '';
}
public function confirmGate(bool $on): void
{
$this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'],
$on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
$on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
$on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'),
danger: ! $on, icon: 'shield');
}
#[On('wgGateToggle')]
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
} catch (InvalidConfirmToken) {
return;
}
$this->runGate($this->onFlag($payload), $bridge);
}
public function runGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
$bridge ??= app(WgBridge::class);
$this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []);
$this->pendingAction = $on ? 'gate-up' : 'gate-down';
$this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
}
/**
* The SSH lock (port 22). Turning it ON is the most dangerous action on this page: a user with no
* surviving WG access locks themselves out of SSH entirely (only the server console / `clusev wg
* down` recovers it). The confirm body spells that out and recommends a backup peer first.
*/
public function confirmSshGate(bool $on): void
{
$this->openConfirm('wgSshGate', ['on' => $on ? '1' : '0'],
$on ? __('wireguard.ssh_lock_on_title') : __('wireguard.ssh_lock_off_title'),
$on ? __('wireguard.ssh_lock_on_body') : __('wireguard.ssh_lock_off_body'),
$on ? __('wireguard.ssh_lock_turn_on') : __('wireguard.ssh_lock_turn_off'),
danger: $on, icon: 'alert');
}
#[On('wgSshGate')]
public function applySshGate(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
} catch (InvalidConfirmToken) {
return;
}
$this->runSshGate($this->onFlag($payload), $bridge);
}
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if (! $this->throttle()) {
return;
}
$bridge ??= app(WgBridge::class);
$this->pendingId = $bridge->request($on ? 'gate-ssh-on' : 'gate-ssh-off', []);
$this->pendingAction = $on ? 'gate-ssh-on' : 'gate-ssh-off';
$this->audit($on ? 'wg.ssh-lock' : 'wg.ssh-unlock', $on ? 'on' : 'off');
}
public function confirmSetPort(): void
{
$this->validate(
['newPort' => ['required', 'regex:/^\d{1,5}$/']],
['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')],
);
if (! $this->portInRange($this->newPort)) {
$this->addError('newPort', __('wireguard.port_invalid'));
return;
}
$this->openConfirm('wgSetPort', ['port' => $this->newPort],
__('wireguard.port_confirm_title'), __('wireguard.port_confirm_body'),
__('wireguard.apply'), danger: true, icon: 'alert');
}
#[On('wgSetPort')]
public function applySetPort(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$port = (string) ($payload['params']['port'] ?? '');
$this->pendingId = $bridge->request('set-port', ['port' => $port]);
$this->pendingAction = 'set-port';
$this->audit('wg.set-port', $port);
$this->newPort = '';
}
public function confirmSetSubnet(): void
{
$this->validate(
['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']],
['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')],
);
$this->openConfirm('wgSetSubnet', ['subnet' => $this->newSubnet],
__('wireguard.subnet_confirm_title'), __('wireguard.subnet_confirm_body'),
__('wireguard.apply'), danger: true, icon: 'alert');
}
#[On('wgSetSubnet')]
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$subnet = (string) ($payload['params']['subnet'] ?? '');
$this->pendingId = $bridge->request('set-subnet', ['subnet' => $subnet]);
$this->pendingAction = 'set-subnet';
$this->audit('wg.set-subnet', $subnet);
$this->newSubnet = '';
}
public function pollResult(WgBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time(); // first poll after a request — start the clock
$res = $bridge->result($this->pendingId);
if ($res === null) {
// No host response after a while → the host watcher (systemd) likely isn't running yet.
// Stop spinning forever and tell the operator instead of leaving "Wird angewendet …".
if (time() - $this->pendingSince > 30) {
$this->pendingId = null;
$this->pendingAction = null;
$this->pendingSince = null;
$this->dispatch('notify', message: __('wireguard.no_host_response'), level: 'error');
}
return;
}
$this->pendingId = null;
$this->pendingSince = null;
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
$this->resultConfig = $res['config'];
$this->resultQr = $res['qr'];
$this->resultName = $this->pendingName;
} elseif (! $res['ok']) {
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
// Persist the failure so it's readable later in the audit log, not just a transient toast.
$this->audit('wg.action-failed', trim(($this->pendingAction ?? '').': '.$msg));
$this->dispatch('notify', message: $msg, level: 'error');
} else {
$this->dispatch('notify', message: __('wireguard.action_done'));
}
$this->pendingAction = null;
$this->pendingName = null;
}
public function dismissResult(): void
{
$this->resultConfig = null;
$this->resultQr = null;
$this->resultName = null;
}
/**
* Download the show-once client config as a .conf file. The filename becomes the tunnel name when
* imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer
* name e.g. "203.0.113.10-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
*/
public function downloadConfig(): ?StreamedResponse
{
if ($this->resultConfig === null) {
return null;
}
$cfg = $this->resultConfig;
$host = preg_match('/^Endpoint\s*=\s*([^:\s]+)/m', $cfg, $m) === 1 ? $m[1] : 'clusev';
$parts = array_filter([$host, (string) $this->resultName]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '-', implode('-', $parts));
$base = trim((string) $base, '-.') ?: 'clusev-wireguard';
return response()->streamDownload(function () use ($cfg) {
echo $cfg;
}, $base.'.conf', ['Content-Type' => 'text/plain; charset=UTF-8']);
}
/** Clamp a traffic window to one of the allowed values, falling back to the shortest. */
private function clampWindow(int $seconds): int
{
return in_array($seconds, self::WINDOWS, true) ? $seconds : self::WINDOWS[0];
}
/** True when a numeric-string port is within the valid UDP range. */
private function portInRange(string $port): bool
{
return (int) $port >= 1 && (int) $port <= 65535;
}
/** Decode the sealed on/off flag a gate-toggle confirm token carries. */
private function onFlag(array $payload): bool
{
return ($payload['params']['on'] ?? '0') === '1';
}
/**
* Open the shared ConfirmAction modal (R5) for one WG action. The issued token carries NO audit
* descriptor on purpose each apply handler audits exactly once itself, so auditing here too
* would double-count. Heading/body/label/danger/icon/params are the only per-action differences.
*
* @param array<string, string> $params
*/
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => $body,
'confirmLabel' => $confirmLabel,
'danger' => $danger,
'icon' => $icon,
'notify' => '',
'token' => ConfirmToken::issue($event, $params),
],
);
}
private function throttle(): bool
{
$key = 'wg-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 10)) {
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
return false;
}
RateLimiter::hit($key, 60);
return true;
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(WgStatus $wg, WgTraffic $traffic)
{
$window = $this->clampWindow($this->window);
// Traffic samples land at most once per minute (clusev:wg-sample), but the page polls
// every 5s (wire:poll) — so re-bucketing the whole WgTrafficSample history on every poll
// is ~11/12 wasted work. Cache the aggregate, keyed on the window AND a data fingerprint
// (MAX(id) busts on any insert, COUNT busts on a prune) so new/changed data invalidates it
// immediately — MAX(sampled_at) alone could collide for two sample sets in the same second.
$fp = WgTrafficSample::query()->selectRaw('COALESCE(MAX(id), 0) AS mid, COUNT(*) AS cnt')->first();
$series = Cache::remember(
"wg:traffic:series:{$window}:{$fp->mid}:{$fp->cnt}",
60,
fn () => $traffic->series($window),
);
return view('livewire.wireguard.index', [
'status' => $wg->read(),
'traffic' => $series,
'windows' => self::WINDOWS,
])->title(__('wireguard.title'));
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use App\Models\AlertIncident;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Queued alert e-mail — one per fire/resolve transition. Plain text (no external assets). */
class AlertNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public AlertIncident $incident,
public bool $resolved,
) {}
public function envelope(): Envelope
{
$server = $this->incident->server?->name ?? '—';
$rule = $this->incident->rule?->name ?? '—';
$prefix = $this->resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing');
return new Envelope(subject: "[{$prefix}] {$rule}{$server}");
}
public function content(): Content
{
// Branded HTML (table layout, inline styles, CID-embedded logo — no external assets, so it
// renders in Gmail/Outlook without image blocking) + the plain-text part as fallback.
return new Content(
html: 'mail.alert-html',
text: 'mail.alert',
with: [
'incident' => $this->incident,
'resolved' => $this->resolved,
'server' => $this->incident->server?->name ?? '—',
'rule' => $this->incident->rule?->name ?? '—',
'metric' => $this->incident->rule?->metric ?? '—',
'value' => $this->incident->value,
'threshold' => $this->incident->rule?->threshold ?? 0,
],
);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
class AlertIncident extends Model
{
/** Sidebar firing-count badge cache key — busted by forgetFiringCount() on every fire/resolve. */
public const FIRING_COUNT_CACHE = 'alerts:firing_count';
protected $guarded = [];
protected $casts = [
'value' => 'float',
'started_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function rule(): BelongsTo
{
return $this->belongsTo(AlertRule::class, 'alert_rule_id');
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function isFiring(): bool
{
return $this->state === 'firing';
}
/**
* Firing-incident count for the sidebar badge. Cached (the sidebar renders on every page), but
* the cache is busted the instant an incident fires or resolves (forgetFiringCount), so the badge
* matches the content on the next render instead of lagging up to the TTL.
*/
public static function firingCount(): int
{
return (int) Cache::remember(self::FIRING_COUNT_CACHE, 60, fn () => static::where('state', 'firing')->count());
}
public static function forgetFiringCount(): void
{
Cache::forget(self::FIRING_COUNT_CACHE);
}
}

77
app/Models/AlertRule.php Normal file
View File

@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* A threshold rule over a server metric. When a targeted server crosses it, the AlertEvaluator
* opens a firing AlertIncident and notifies; when it recovers, the incident resolves.
*/
class AlertRule extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'threshold' => 'float', // % metrics are whole, but `load` is fractional (see the migration)
];
/** Metrics a rule can watch. `offline` fires when a server can't be polled (status offline). */
public const METRICS = ['cpu', 'mem', 'disk', 'load', 'offline'];
public const COMPARATORS = ['gt', 'lt'];
public const SCOPES = ['all', 'group', 'server'];
protected static function booted(): void
{
static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function incidents(): HasMany
{
return $this->hasMany(AlertIncident::class);
}
/** Whether this rule applies to the given server (all / a group's members / one server). */
public function targets(Server $server): bool
{
return match ($this->scope_type) {
'server' => (int) $this->scope_id === $server->id,
'group' => $server->groups()->where('server_groups.id', $this->scope_id)->exists(),
default => true, // 'all'
};
}
/**
* Does the reading breach this rule? For numeric metrics, compare against the threshold with the
* rule's comparator; for `offline`, the breach is simply that the server is not online.
*
* @param array<string, int|string> $metrics cpu/mem/disk/load ints; status when offline
*/
public function breached(array $metrics, string $status): bool
{
if ($this->metric === 'offline') {
return $status === 'offline'; // only a truly-down server, not a reachable "warning"
}
$value = (float) ($metrics[$this->metric] ?? 0);
$threshold = (float) $this->threshold;
return $this->comparator === 'lt' ? $value < $threshold : $value > $threshold;
}
/** The reading to record on the incident (the breached value; 0 for offline). Float for `load`. */
public function reading(array $metrics): float
{
return $this->metric === 'offline' ? 0.0 : (float) ($metrics[$this->metric] ?? 0);
}
}

View File

@ -4,10 +4,14 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AuditEvent extends Model
{
/** Sidebar threats-count badge cache key — busted by forgetThreatLoginCount() on each fake login. */
public const THREAT_COUNT_CACHE = 'threats:login_attempts_24h';
protected $guarded = [];
protected $casts = ['meta' => 'array'];
@ -31,4 +35,57 @@ class AuditEvent extends Model
{
return $this->belongsTo(Server::class);
}
/**
* Fake-login (honeypot) attempts in the last 24h for the sidebar Threats badge. Cached (the
* sidebar renders on every page) but busted the instant one is recorded (forgetThreatLoginCount),
* so the badge stays in step with the Threats page instead of lagging up to the TTL.
*/
public static function threatLoginCount24h(): int
{
return (int) Cache::remember(
self::THREAT_COUNT_CACHE,
60,
fn () => static::where('action', 'security.honeypot_login')->where('created_at', '>', now()->subDay())->count(),
);
}
public static function forgetThreatLoginCount(): void
{
Cache::forget(self::THREAT_COUNT_CACHE);
}
/** Actions that record a failure or security event — surfaced with a warning style. */
private const ERROR_ACTIONS = [
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
'security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used',
'service.start.failed', 'service.stop.failed', 'service.restart.failed',
];
/**
* Human-readable, localized label for the raw action code (e.g. "wg.set-endpoint" ->
* "WireGuard-Endpoint geändert"). Falls back to a tidied form for unmapped/dynamic codes
* (e.g. the dynamic "harden.*.on|off") so nothing ever shows a bare machine code awkwardly.
*/
public function getActionLabelAttribute(): string
{
$labels = (array) trans('audit.actions');
return $labels[$this->action] ?? $this->prettyAction();
}
/** True when the entry represents a failure / security alert (drives the warning styling). */
public function getIsErrorAttribute(): bool
{
return in_array($this->action, self::ERROR_ACTIONS, true);
}
private function prettyAction(): string
{
$s = str_replace(['_', '-'], ' ', str_replace('.', ' · ', (string) $this->action));
return Str::ucfirst(trim($s));
}
}

19
app/Models/BannedIp.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class BannedIp extends Model
{
protected $guarded = [];
protected $casts = ['banned_until' => 'datetime'];
/** Non-expired bans only. */
public function scopeActive(Builder $query): Builder
{
return $query->where('banned_until', '>', now());
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A TLS endpoint (host:port) whose certificate expiry Clusev monitors. */
class CertEndpoint extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
protected static function booted(): void
{
static::creating(fn (self $e) => $e->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** An uptime probe: an HTTP URL or a TCP host:port whose reachability Clusev monitors. */
class HealthCheck extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
public const TYPES = ['http', 'tcp'];
protected static function booted(): void
{
static::creating(fn (self $c) => $c->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* The SSH login for the "Clusev host" terminal (a singleton one host). Stored encrypted; only
* ever read over the internal network by the terminal resolve endpoint, never sent to the browser.
* When no row exists the host terminal is "not configured" and the tile opens the setup form.
*/
class HostCredential extends Model
{
/** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */
protected $fillable = ['host', 'port', 'username', 'auth_type', 'secret', 'passphrase'];
/** Never serialize the credential fields (mirrors SshCredential). */
protected $hidden = ['secret', 'passphrase'];
protected $casts = [
'port' => 'integer',
'secret' => 'encrypted',
'passphrase' => 'encrypted',
];
/** The single configured host login, or null when the operator hasn't set one up yet. */
public static function current(): ?self
{
return static::query()->orderBy('id')->first();
}
/**
* A TRANSIENT Server (never persisted) that points SSH tooling FleetService, DockerService
* at the Clusev host itself, reusing the encrypted host login. The SshCredential is likewise
* transient; its encrypted 'secret' cast round-trips the plaintext back for CredentialVault.
* verifyHostKey skips pinning a non-persisted server, so this creates no fleet row.
*/
public function toServer(): Server
{
$cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]);
$cred->secret = $this->secret;
if ($this->passphrase !== null && $this->passphrase !== '') { // not `if ($this->passphrase)` — a "0" passphrase is valid
$cred->passphrase = $this->passphrase;
}
$server = new Server([
'name' => 'Clusev Host',
'ip' => $this->host,
'ssh_port' => $this->port,
'status' => 'online',
]);
$server->setRelation('credential', $cred);
return $server;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One persisted point of a server's resource history (cpu/mem/disk %, load), sampled once a minute
* by clusev:sample-metrics. The live 15s view stays cache-backed; this table is the LONG history
* the Server-Details graph plots over selectable ranges. Pruned to a retention window.
*/
class MetricSample extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'cpu' => 'integer',
'mem' => 'integer',
'disk' => 'integer',
'load' => 'float',
'sampled_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
}

22
app/Models/Runbook.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A saved, reusable fleet command. */
class Runbook extends Model
{
protected $guarded = [];
protected static function booted(): void
{
static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -2,7 +2,9 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
@ -30,4 +32,26 @@ class Server extends Model
{
return $this->hasOne(SshCredential::class);
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(ServerGroup::class);
}
/** Servers that belong to the given group (by group id). */
public function scopeInGroup(Builder $query, int $groupId): void
{
$query->whereHas('groups', fn (Builder $q) => $q->where('server_groups.id', $groupId));
}
/**
* Servers whose stored SSH credential is present AND not disabled the live poll set.
* A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect
* time, but the poller reuses a long-lived connection, so revocation only takes effect if
* the server also drops OUT of this query on the next tick.
*/
public function scopeWithActiveCredential(Builder $query): void
{
$query->whereHas('credential', fn (Builder $q) => $q->whereNull('disabled_at'));
}
}

Some files were not shown because too many files have changed in this diff Show More