Compare commits

..

187 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
388 changed files with 21563 additions and 17599 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
@ -47,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):
@ -65,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.
@ -85,6 +90,16 @@ ACME_EMAIL=
# .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
@ -96,6 +111,20 @@ SITE_ADDRESS=:80
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}"

35
.gitignore vendored
View File

@ -14,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
@ -34,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/

View File

@ -7,12 +7,271 @@ Alle nennenswerten Änderungen an Clusev. Format nach
**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand
reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer
eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich
getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Builds.
getaggte Releases (Kanal `stable`) — niemals Entwicklungs-Builds.
**Was hier steht:** nur, was Nutzer der Installation merken — knapp, ein Eintrag
pro Änderung, gruppiert nach _Hinzugefügt / Geändert / Behoben / Sicherheit_. Rein
interne Arbeit (Wartungs-Tooling, CI, Refactorings, Tests) gehört nicht hierher,
sondern in die git-Historie.
## [Unreleased]
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.11.0] - 2026-07-05
### Geändert
- **Ein einziger stabiler Release-Kanal.** Nutzer erhalten ausschließlich finalisierte `vX.Y.Z`-Releases;
die interne Release-Steuerung schneidet jetzt Release Candidates statt Vorab-Kanälen.
## [0.11.0-rc1] - 2026-06-26
### Hinzugefügt
- **Web-Terminal.** Vollwertiges SSH-Terminal direkt im Browser — eines pro Server und eines für den
Clusev-Host selbst. Echtes PTY mit Tab-Autovervollständigung; ein eigener Reiter im Menü.
- **Geführte Einführungs-Tour.** Beim ersten Login zeigt ein Spotlight-Rundgang die wichtigsten
Bereiche. Überspringbar und jederzeit in den Einstellungen erneut startbar.
- **Server-Suche im Terminal.** Ab mehreren Servern lässt sich die Zielliste durchsuchen.
- **Eigenes Passwort beim Anlegen eines Kontos.** Beim Hinzufügen eines Admins kann direkt ein
Passwort vergeben werden (leer = sicheres Einmal-Passwort, das einmalig angezeigt wird).
### Geändert
- **Passwortwechsel ist optional.** Der erste Login erzwingt kein neues Passwort mehr — ein
Warnbanner erinnert daran, sperrt aber nicht aus. 2FA war und bleibt optional (nur die
Login-Texte waren irreführend und wurden korrigiert).
- **Passwort-Mindestlänge auf 6 Zeichen** gesenkt, ohne Komplexitätszwang — die Passwortstärke
liegt in der Verantwortung des Nutzers.
- **Zufälliges Erst-Passwort.** Der Installer erzeugt ein zufälliges Einmal-Passwort statt des
festen `clusev` und zeigt es nur einmal an.
- **Installer fragt nicht mehr nach der Domain.** Die Installation läuft über IP/HTTP; die Domain
(mit automatischem HTTPS) wird später im Dashboard unter System gesetzt.
- **2FA-Status in der Seitenleiste** als dezentes Schild-Symbol statt Text-Badge.
### Sicherheit
- **Host-Key-Pinning** für die Terminal-SSH-Verbindungen — eine geänderte Server-Kennung (möglicher
MITM) bricht die Verbindung ab, bevor Zugangsdaten übertragen werden.
- **Proxy-Vertrauen gehärtet:** nur noch private Netze werden vertraut, damit ein gefälschter
`X-Forwarded-For` die IP-basierten Brute-Force-Limits nicht mehr umgehen kann.
## [0.10.0-rc1] - 2026-06-25
### Hinzugefügt
- **Verlaufs-Graf auf der Server-Detailseite.** CPU, RAM und Disk über Zeit — Zeitraum wählbar
(1 h / 24 h / 7 d / 30 d), mit Hover-Tooltip; der gewählte Zeitraum bleibt in der URL erhalten.
- **SSH-Schlüssel benennen.** Beim Hinzufügen eines Schlüssels lässt sich ein Name vergeben, sodass
Schlüssel in der Liste zuordenbar sind.
### Behoben
- **Aussperrschutz beim SSH-Root-Login.** Solange Clusev sich als `root` verbindet, lässt sich
„SSH-Root-Login deaktivieren" nicht mehr ausführen — das hätte den eigenen Zugang gekappt.
- **fail2ban startet zuverlässig.** Die `sshd`-Jail liest jetzt das systemd-Journal statt
`/var/log/auth.log` — so läuft fail2ban auch auf minimalen Images ohne rsyslog an.
## [0.9.68] - 2026-06-23
### Intern
- **Release-Tooling im Dev-Dashboard + CI-Pipeline.** Release Candidate schneiden, Live-Pipeline-Status,
Veröffentlichen / Promote-to-Stable / Yank — für Nutzer der Installation nicht sichtbar.
Details in der git-Historie. (Sammelt die internen Versionen 0.9.590.9.68.)
## [0.9.58] - 2026-06-22
### Hinzugefügt
- **Update-Check unterstützt jetzt GitHub.** Public-Nutzer sehen Updates aus dem öffentlichen
GitHub-Repo (Tags + Changelog); interne Dev/Staging-Stufen bleiben privat.
### Geändert
- **Öffentliche Projekt-Adresse ist jetzt `github.com/clusev/clusev`.** README, Quelltext- und
Konsolen-Branding sowie die Standard-Update-Quelle zeigen aufs öffentliche Repo.
## [0.9.57] - 2026-06-22
### Hinzugefügt
- **Stabiler Release-Kanal `stable`.** Nutzer beziehen ausschließlich stabile Releases (`vX.Y.Z`) —
niemals Entwicklungs-Builds.
## [0.9.56] - 2026-06-22
### Hinzugefügt
- **Branding im Quelltext + in der Browser-Konsole.** Wer den Seitenquelltext ansieht, findet jetzt
einen freundlichen Clusev-Hinweis (wer wir sind, Projektlink) als HTML-Kommentar. Öffnet jemand die
Browser-Konsole, erscheint ein gestyltes „CLUSEV"-Branding mit Projektlink — **und eine
Self-XSS-Sicherheitswarnung**: bei einem Server-Control-Panel ist „Füg das mal in deine Konsole ein"
ein realer Social-Engineering-Angriff, daher der Hinweis genau dort. Erscheint auf jeder Seite (auch
vor dem Login).
### Behoben
- **Datei-Manager: Dateiname im Tablet-Modus vom Rechte-Block überdeckt.** Die Tabellenspalten
(Rechte/Größe/Geändert) + Aktions-Buttons quetschten die Namensspalte auf nahezu null. Die Liste
rendert jetzt **unterhalb großer Bildschirme als gestapelte Karte** (Name in voller Breite, Meta-Zeile
und Aktionen darunter); die Tabellenansicht erscheint erst auf breiten Displays. Die mobile Ansicht
wurde dabei mit aufgeräumt.
- **Zeiger-Cursor auf Buttons fehlte.** Tailwind v4 stellt Buttons standardmäßig auf den
Standard-Cursor; auf anklickbaren Elementen (z. B. Ordnernamen, alle Buttons) erscheint jetzt wieder
der Hand-Zeiger.
### Behoben
- **Datei-Manager: „undefined is not valid JSON" beim Öffnen mancher Dateien.** Öffnete man eine
**Binär-/Nicht-UTF-8-Datei**, landeten deren Rohbytes in einer Livewire-Eigenschaft. Livewire
JSON-kodiert seinen Zustand, und ungültiges UTF-8 lässt `json_encode` scheitern → der Client bekam
eine leere Antwort und warf den Fehler. Binär-/zu-große Dateien werden jetzt nicht mehr als Inhalt
gebunden (es erscheint der Binär-Hinweis), sodass der Zustand immer gültiges JSON bleibt — sowohl
im Editor als auch in der zugrunde liegenden Datei-Lese-Funktion abgesichert.
### Behoben
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
ohne stabilen `wire:key` ordnete Livewires DOM-Abgleich die Zeilen beim Neu-Rendern nach Position
zu und verlor dabei den Alpine-Zustand der Buttons (Modal-Auslöser) — die Konsole warf „pending is
not defined" und Aktionen klemmten. Alle betroffenen Listen haben jetzt einen stabilen `wire:key`:
Datei-Manager (Einträge + Pfad), Dienste, Release-Kanal-Auswahl, Benutzer, Sicherheitsschlüssel,
IP-Sperren sowie die Server-Detailseite (Härtung, Firewall-Regeln, SSH-Schlüssel, fail2ban-Ausnahmen).
### Behoben
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard
wurden noch als `wg.set-endpoint` / `deploy.update_request` angezeigt (die /audit-Seite war bereits
lesbar). Jetzt zeigt auch das Dashboard die verständlichen Bezeichnungen, und fehlgeschlagene /
sicherheitsrelevante Ereignisse erscheinen rot mit Warn-Symbol — konsistent mit der Audit-Seite.
### Behoben
- **„Was ist neu" wurde nicht angezeigt.** Der Vorschau-Block lud die `CHANGELOG.md` am Tag `0.9.50`,
die Release-Tags heißen aber `v0.9.50` — der Abruf lief ins Leere (404) und der Block blieb leer.
Jetzt wird die richtige `v`-präfixierte Tag-Referenz verwendet; die Änderungen der verfügbaren
Version erscheinen wieder vor dem Update.
### Hinzugefügt
- **Update-Hinweis in der Seitenleiste.** Ist eine neue Version verfügbar, zeigt der Menüpunkt
**Version** jetzt ein kleines **„1"-Abzeichen** — man sieht ein Update also auf jeder Seite, nicht
erst auf der Version-Seite. Der Status wird von einer geplanten Prüfung (alle 30 Min, anonym und
nur lesend gegen den Git-Host) im Cache warm gehalten; die Seitenleiste macht selbst keinen Netzabruf.
### Geändert
- Die Release-Prüfung (Version-Seite, Seitenleisten-Abzeichen, geplante Prüfung) nutzt jetzt einen
gemeinsamen `ReleaseChecker`-Dienst (vorher in der Version-Seite verkapselt).
## [0.9.50] - 2026-06-21
### Hinzugefügt
- **Audit-Log mit Seiten-Navigation.** Das Log war auf die letzten 50 Einträge begrenzt; jetzt ist es
paginiert (25 pro Seite), die aktuelle Seite steht in der URL (`?page=`) und ist verlinkbar.
### Geändert
- **Audit-Suche durchsucht den gesamten Verlauf.** Die Filterung läuft jetzt in der Datenbank über
alle Einträge (nicht nur die geladene Seite) — über Akteur, Aktion, Ziel, Server **und** die
lesbaren Bezeichnungen (z. B. findet „Endpoint geändert" die `wg.set-endpoint`-Einträge).
### Hinzugefügt
- **Was ist neu — vor dem Update sichtbar.** Ist eine neue Version verfügbar, zeigt die Version-Seite
jetzt ein „Das ist neu in vX.Y.Z"-Feld mit dem Änderungsprotokoll der **verfügbaren** Version(en) —
geladen aus der `CHANGELOG.md` des neuesten Release-Tags. So sieht man **vor** dem Aktualisieren,
was sich ändert, und muss das Update nicht erst durchführen. (Anonym + nur lesend, kurz gecacht;
bei fehlender Verbindung bleibt der Update-Knopf unverändert nutzbar.)
### Geändert
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
das Audit-Log jetzt verständliche Beschreibungen („Administrator · WireGuard-Endpoint geändert").
Alle Aktionen sind übersetzt (DE/EN); unbekannte/dynamische Codes werden sauber aufbereitet. Die
Suche findet auch die lesbaren Bezeichnungen.
### Hinzugefügt
- **Fehler im Audit-Log sichtbar.** Fehlgeschlagene oder sicherheitsrelevante Ereignisse (fehlge­schlagene
Anmeldung, IP-Sperre, fehlgeschlagene 2FA …) werden jetzt **rot mit Warn-Symbol** hervorgehoben.
Zusätzlich landen **fehlgeschlagene WireGuard-Aktionen** mit der Host-Fehlermeldung im Audit-Log
(statt nur als kurzer Hinweis), sodass man später nachlesen kann, was schiefging.
### Geändert
- **Endpoint-Feld ist jetzt „nur Host".** Es wirkte, als müsse man den Port zweimal eintragen (einmal
als Listen-Port, einmal im Endpoint). Tatsächlich genügt der Host: Label heißt jetzt „Öffentlicher
Endpoint (Host)", der Platzhalter zeigt nur den Host (ohne `:Port`), und der Hinweis erklärt, dass
der Port automatisch vom Listen-Port kommt. Ein abweichender Port ist nur bei Portweiterleitung
nötig (`host:port`). Funktion war seit 0.9.43 schon so — jetzt ist es auch in der Oberfläche klar.
### Behoben
- **Traffic-Kopfzeile zeigte 0 B, obwohl der Peer Traffic hatte.** „Empfangen/Gesendet" oben speiste
sich aus dem Sample-Durchsatz (0, solange wenig Traffic floss), während die Peer-Zeile den
Live-Zähler zeigte — inkonsistent. Die Kopfzeile zeigt jetzt die **Live-Summe der Peer-Zähler**
(passt damit immer zur Peer-Zeile). Das Diagramm darunter bleibt der zeitfenster-bezogene Durchsatz
(füllt sich über den minütlichen Sampler, sobald spürbarer Traffic fließt).
### Geändert
- **Klarstellung QR-Import am Handy.** Der Hinweis sagt jetzt eindeutig: die WireGuard-App fragt beim
QR-Scan **immer** nach einem Tunnel-Namen (App-Vorgabe, nicht abschaltbar). Für einen automatischen
Namen die `.conf` **herunterladen** und importieren (Dateiname = Tunnel-Name).
### Hinzugefügt
- **DNS individuell einstellbar.** Der DNS-Server in den Client-Configs war fest auf `1.1.1.1`. Jetzt
in der Server-Konfiguration frei wählbar (z. B. dein eigenes Gateway `10.0.0.1`, mehrere
kommagetrennt) — gilt für künftige Peer-Configs, host-seitig validiert (nur IPv4). Auch per CLI:
`clusev wg set-dns <ip[,ip]>`. Bestehende Tunnel bekommen den Wert beim ersten Setzen ergänzt.
- **WireGuard-Seite in Tabs aufgeteilt.** Zwei unabhängige Reiter: **Peers** (Liste, Anlegen,
Entfernen, Traffic) und **Server-Konfiguration** (Endpoint, DNS, Port, Subnetz, Gate, SSH-Sperre +
Server-Identität). Der aktive Tab ist deep-linkbar (`?tab=server`). So lassen sich Server-Einstellungen
getrennt von der Peer-Verwaltung ändern.
### Geändert
- **Offline-Erkennung schneller.** Das Handshake-Fenster für „online" wurde von 180 s auf 150 s
gesenkt — ein abgeschalteter Peer wird ~2,5 statt ~3 min nach dem Trennen offline (WireGuard ist
verbindungslos, schneller geht es ohne Flackern aktiver Peers nicht).
- **Endpoint-Hinweis klargestellt:** der Port ist optional (fehlt er, wird der Listen-Port ergänzt) —
man muss ihn nicht doppelt zum Listen-Port eintragen.
### Geändert
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
damit das Scannen am Handy zuverlässig bleibt.
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
`<endpoint-host>-<peer>.conf` (z. B. `203.0.113.10-laptop.conf`) statt `clusev-wireguard.conf`.
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
### Behoben
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein
Host ohne Port eingegeben (z. B. `203.0.113.10` statt `203.0.113.10:51820`), landete dieser
Wert unverändert in den Peer-Configs — WireGuard verlangt aber zwingend `host:port`, sodass die
WireGuard-App den (QR- oder Datei-)Import als „keine gültige WireGuard-Konfiguration" ablehnte.
Der Listen-Port wird jetzt automatisch ergänzt, wenn der Endpoint keinen Port enthält (sowohl bei
der Einrichtung als auch beim späteren Ändern). Bestehende Konfigurationen: einmal den Endpoint
unter Einstellungen neu speichern (oder mit Port angeben), dann einen neuen Peer anlegen.
### Behoben
- **Peer-QR-Code ließ sich am Handy nicht scannen („keine gültige WireGuard-Konfiguration").** Der
QR einer WireGuard-Client-Config ist dicht (~57 Module); er wurde im Einmal-Dialog mit nur 176 px
angezeigt (~2,7 px pro Modul) und kantengeglättet — zu klein/unscharf, sodass die Handy-Kamera ihn
fehlerhaft las. Der QR wird jetzt deutlich größer (bis ~300 px) auf weißem Rahmen mit Ruhezone und
pixelgenau (`shape-rendering: crispEdges`) gerendert. Alternativ bleibt der **Herunterladen**-Knopf
(Config als `.conf` am PC importieren).
### Behoben
- **WireGuard-Aktionen aus dem Dashboard liefen auf dem Host ins Leere („Keine Antwort vom Host").**
Der Host-Dienst, der die Dashboard-Anfragen verarbeitet (`clusev-wg.sh serve-request`), brach unter
`set -euo pipefail` ab, **bevor** er eine Antwort schrieb — sobald ein Feld leer oder gar nicht
vorhanden war: ein leeres Endpoint-Feld bei der Einrichtung („auto"), oder Aktionen ohne
bestimmte Felder (Gate ein/aus, Peer anlegen/entfernen). Dadurch wartete die Oberfläche ~30 s und
meldete „Keine Antwort vom Host", obwohl der Watcher korrekt lief. Zwei Ursachen behoben:
(1) die Feld-Extraktion per `grep` bricht bei fehlendem Treffer nicht mehr den ganzen Handler ab;
(2) jede Aktion läuft jetzt in einer Subshell, sodass ein interner Abbruch (`die` bei noch nicht
eingerichtetem Tunnel) immer eine saubere Fehlerantwort liefert statt eines Timeouts. Damit
funktionieren Einrichtung, Peer-Verwaltung, Gate und SSH-Sperre aus dem Dashboard auf einem echten
Host. Neuer Smoke-Test (`docker/wg/serve-request.test.sh`) sichert das ab.
## [0.9.40] - 2026-06-21
### Hinzugefügt
- **Peer-Konfiguration herunterladen.** Die Einmal-Anzeige eines neuen Clients hat neben dem
QR-Code jetzt einen **Herunterladen**-Knopf: Die Konfiguration lässt sich als
`clusev-wireguard.conf` speichern und am PC in die WireGuard-App importieren — alternativ zum
Scannen des QR-Codes am Handy. Ein Hinweis erklärt beide Wege direkt in der Anzeige.
- **SSH-Sperre aus dem Dashboard (Port 22).** In den WireGuard-Einstellungen lässt sich SSH
optional **zusätzlich** auf den Tunnel sperren — danach ist Port 22 nur noch über WireGuard bzw.
die Server-Konsole erreichbar. Die Aktion ist mit einer deutlichen **Aussperr-Warnung** versehen
(Inline-Hinweis **und** Bestätigungsdialog): Wer seinen WG-Zugang verliert, kommt per SSH nicht
mehr auf den Host — Empfehlung, vorher einen zweiten WG-Peer als Backup-Zugang anzulegen. Auf das
Dashboard kommt man auch ohne SSH weiter über den Tunnel. Bestehende SSH-Sitzungen bleiben
verbunden; ein nicht laufender Tunnel verhindert das Aktivieren. Neue CLI-Notausgänge:
`clusev wg ssh-lock` / `clusev wg ssh-unlock`, und `clusev wg down` (Server-Konsole) hebt alle
Sperren auf. Die Sperre übersteht Neustarts nur bei aktivem Tunnel — kommt wg0 nicht hoch, bleibt
SSH offen (kein Aussperren durch einen kaputten Boot).
## [0.9.39] - 2026-06-21
### Geändert
@ -472,7 +731,7 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
Version veröffentlicht war. Der neueste Tag im Kanal wird jetzt über die **öffentliche, lesende
Tag-API des Git-Hosts** abgefragt (anonym — kein Token verlässt das Panel), mit lokalem `.git` nur
noch als Dev-Fallback; das Ergebnis ist gecacht, sodass ein Seitenaufruf nie eine Netzwerkanfrage
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet nur `vX.Y.Z`, `beta` auch Vorab-Tags.
auslöst (nur der Knopf selbst). Kanal-Logik: `stable` bietet ausschließlich `vX.Y.Z` an.
Schlägt die Abfrage fehl (offline o. Ä.), degradiert sie still statt zu blockieren.
## [0.9.6] - 2026-06-19
@ -1042,10 +1301,10 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force-
Drossel, Session-Cookie-Härtung.
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
pro Version, Kanal `stable` (kein nutzer-seitiges `dev`).
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://git.bave.dev/boban/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0
[Unreleased]: https://github.com/clusev/clusev/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/clusev/clusev/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/clusev/clusev/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/clusev/clusev/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/clusev/clusev/releases/tag/v0.1.0

266
CLAUDE.md
View File

@ -1,266 +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.** **Convention: every broadcast channel is
PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the
Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare
events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the
header comment there); per-server channels also scope to records the user may see.
- **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 is localized (DE default + EN)** — never hard-code visible strings; use
`__('group.key')` with translations in `lang/{de,en}/<group>.php` (one group per feature +
shared `common`), keys identical in both languages. Register: terse/operational, **no emoji**
(status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16)
- **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)
- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens
`@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17)
- **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;
**inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key`
text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit.
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.

224
README.md
View File

@ -1,96 +1,198 @@
# Clusev
<div align="center">
**Self-hosted control panel for a fleet of Linux servers — one dashboard, agentless over SSH.**
<img src="art/clusev-banner.svg" alt="Clusev — self-hosted control plane for a fleet of Linux servers" width="100%">
Clusev is the control plane: a modern, security-first web UI that administers your servers by talking
to them directly over SSH (exec + SFTP via phpseclib). It installs nothing on the target machines and
never reimplements their daemons. Multi-server management is free and never paywalled.
<br>
**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>
---
## What is Clusev
**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.
> **Multi-server management is free and never paywalled.** Optional Pro modules (SSO/LDAP, RBAC,
> audit export, alerting) are separate — the core stays open.
<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>
---
## Features
- **Dashboard & live metrics** — CPU, memory, load and disk per server, streamed in real time.
- **systemd services** — list, start/stop/restart, live journal tail.
- **SFTP file manager** — browse, edit text files, preview images.
- **Server details & hardening** — resource gauges, volumes, interfaces, SSH keys; UFW/firewalld
rules and fail2ban status with one-click controls; and a one-click "generate an SSH key and disable
password login, safely" flow.
- **Security** — optional, pluggable 2FA (TOTP and/or hardware security 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 & e-mail** — run on a bare IP over HTTP, set a domain for automatic HTTPS, or put your
own reverse proxy in front. Configure SMTP in-panel for password-reset e-mails.
| 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 panel is built on Laravel 13, Livewire 3, Tailwind v4, Laravel Reverb (realtime), Redis, MariaDB
and phpseclib — all running in Docker. The interface is in German (English available).
The interface is **German by default** (English available). Status is shown the operational way —
colour, dots and pills — never emoji.
## Requirements
---
- A **Debian or Ubuntu** server (a small VM is enough) with **root** access.
- A public IP. Optionally a domain whose DNS points at the server (for automatic HTTPS).
- Only **git** to fetch the repo (one line below) — the installer sets up Docker and everything else.
## Architecture
## Install
<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
sudo apt-get update && sudo apt-get install -y git # minimal images often lack git
git clone https://git.bave.dev/boban/clusev.git
git clone https://github.com/clusev/clusev.git
cd clusev
sudo ./install.sh
```
The installer is idempotent (safe to re-run) and, in one pass:
The installer is **idempotent** (safe to re-run) and, in one pass:
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. Asks for a **domain** (leave empty for IP access) and an **admin e-mail** (leave empty for the
default **`admin@clusev.local`**), or reads `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` from the
environment for an unattended install. If you give a domain it
checks whether DNS already points here: if so a certificate is obtained automatically; if not, it
warns you and lets you take the domain anyway (HTTP until DNS is correct) or continue on the IP.
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 the **default password `clusev`**
(you are forced to change it on first login).
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.
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 the default password **`clusev`**),
and the **`clusev` host user** + its random password (shown **only once** — note it down).
(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.
Log in with your admin e-mail (default **`admin@clusev.local`**) and the password **`clusev`** — the
panel then **forces you to set a new password immediately**. Do this right away: `clusev` is a known
default, so the account is only safe once you've changed it. 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.
### First login
## Access, domain & TLS
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).
---
## 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 during install or later under **System → Domain & TLS**. 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 & 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
- **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
cd clusev
sudo ./update.sh # pulls the latest code, then rebuilds, restarts and migrates
sudo clusev update # pulls the latest code, then rebuilds, restarts and migrates
```
`update.sh` fast-forwards the repo (it never discards local changes), re-runs the idempotent
`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.)
## Account recovery
---
## 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 +
@ -99,8 +201,7 @@ new password) as a fallback.
Completely locked out (lost password and 2FA, no SMTP)? Recover from the host:
```bash
cd clusev
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
clusev reset-admin
```
This clears the second factor so you can set a new password on the next login. The bare-IP
@ -108,8 +209,15 @@ This clears the second factor so you can set a new password on the next login. T
is documented in-panel under Settings → Security and is deliberately not shown on the public
forgot-password screen.)
---
## License
Open core, **AGPL-3.0** — multi-server fleet management is always free; optional Pro modules
(SSO/LDAP, RBAC, audit export, alerting) are separate. Project home:
<https://git.bave.dev/boban/clusev>.
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

@ -4,22 +4,19 @@ namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Idempotent first-run bootstrap (called by install.sh, phase 7).
*
* Creates the initial admin with the standard default password (operator decision: a
* known, memorable default so login is never blocked by a missed one-time secret).
* `must_change_password` is set, so the onboarding gate FORCES a new password on the
* first login the default is only valid until then. Re-running once an admin exists is
* a hard no-op. NOTE: a known default credential is only acceptable because of the forced
* rotation; the operator must change it immediately (banner / MOTD / README say so).
* 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 password — forced to change on first login (must_change_password). */
private const DEFAULT_PASSWORD = 'clusev';
/**
* 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
@ -32,7 +29,7 @@ class Install extends Command
{--email= : Admin-Login + Let's-Encrypt-E-Mail}
{--name=Administrator : Anzeigename des Admins}";
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit Standard-Passwort an (Zwangswechsel beim ersten Login)';
protected $description = 'Idempotenter Erstlauf: legt den ersten Admin mit zufälligem Initialpasswort an (Wechsel empfohlen, nicht erzwungen)';
public function handle(): int
{
@ -50,22 +47,24 @@ class Install extends Command
return self::FAILURE;
}
$password = self::DEFAULT_PASSWORD;
// 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 a new password on first login
'must_change_password' => true, // nudges a rotation on first login (optional)
'role' => 'admin', // the first/root account is a full administrator
]);
// install.sh greps these two lines for the closing banner. The password is the
// standard default and must be changed on first login (must_change_password).
// 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. Standard-Passwort — beim ersten Login sofort aendern.');
$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

@ -40,6 +40,9 @@ class ResetAdmin extends Command
'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')) {
@ -57,7 +60,7 @@ class ResetAdmin extends Command
$this->info("Passwort für {$user->email} zurückgesetzt.");
if (! $this->option('password')) {
$this->warn("Einmal-Passwort (jetzt notieren): {$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.');

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;
}
}

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

@ -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,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 seeded-password rotation before the panel is reachable.
* 2FA is optional (offered in Settings), so it is NOT forced here.
* 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,7 +18,9 @@ class EnsureSecurityOnboarded
{
$user = $request->user();
if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
if ($user && $user->must_change_password
&& ! $request->session()->get('onboarding.password_skipped')
&& ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change');
}

View File

@ -44,8 +44,9 @@ class PanelScheme
}
// 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 likewise).
if ($request->is('_caddy/*', 'up')) {
// 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);
}

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

@ -4,14 +4,21 @@ namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
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 = '';
@ -28,9 +35,19 @@ class Index extends Component
$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'],
]);
@ -62,37 +79,74 @@ class Index extends Component
}
/**
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
* 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 events(): Collection
protected function eventsQuery(): Builder
{
$events = AuditEvent::with('server')->latest()->limit(50)->get();
$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

@ -69,7 +69,7 @@ class ForgotPassword extends Component
$this->validate([
'email' => ['required', 'email'],
'code' => ['required', 'string'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
'password' => ['required', 'confirmed', Password::min(6)],
]);
// Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset

View File

@ -34,7 +34,7 @@ class PasswordChange extends Component
try {
$this->validate([
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
'password' => ['required', 'confirmed', Password::min(6)],
], attributes: [
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
@ -57,6 +57,18 @@ class PasswordChange extends Component
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')->title(__('auth.title_password_change'));

View File

@ -34,7 +34,7 @@ class ResetPassword extends Component
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()],
'password' => ['required', 'confirmed', PasswordRule::min(6)],
]);
$status = Password::reset(

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

@ -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

@ -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

@ -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();
@ -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) {
@ -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,6 +182,8 @@ 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;
@ -200,6 +217,8 @@ class Index extends Component
#[On('fileConfirmed')]
public function deleteEntry(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
} catch (InvalidConfirmToken) {

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

@ -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,12 +45,39 @@ 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);
@ -55,8 +87,11 @@ class AddSshKey extends ModalComponent
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,7 +103,7 @@ 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(),
]);

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

@ -36,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';
@ -87,6 +93,10 @@ class CreateServer extends ModalComponent
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: create server + credential, then VERIFY the SSH login. A failed

View File

@ -2,20 +2,22 @@
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 administrator account. Every account is a full admin
* (no roles). We generate a strong one-time password, store it hashed, and flag
* the account `must_change_password` so the new admin must rotate it on first
* sign-in. The clear-text temp password is revealed ONCE in the modal and never
* persisted or logged.
* 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
{
@ -23,12 +25,23 @@ class CreateUser extends ModalComponent
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';
@ -42,6 +55,9 @@ class CreateUser extends ModalComponent
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)],
];
}
@ -53,11 +69,15 @@ class CreateUser extends ModalComponent
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.
@ -68,13 +88,17 @@ class CreateUser extends ModalComponent
return;
}
$temp = Str::password(16);
// 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($temp),
'must_change_password' => true,
'password' => Hash::make($password),
'must_change_password' => ! $operatorSet,
'role' => $data['role'],
]);
AuditEvent::create([
@ -85,9 +109,18 @@ class CreateUser extends ModalComponent
'ip' => request()->ip(),
]);
// Reveal the temp password ONCE — swaps the form for the reveal panel.
// 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 = $temp;
$this->tempPassword = $password;
}
/** "Fertig": reload the list and close. */

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,6 +49,9 @@ 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);

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,6 +46,8 @@ 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);

View File

@ -35,6 +35,8 @@ class Fail2banBans extends ModalComponent
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;
}
@ -77,6 +79,8 @@ class Fail2banBans extends ModalComponent
*/
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');

View File

@ -35,6 +35,8 @@ 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);
@ -64,6 +66,8 @@ 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) {

View File

@ -78,6 +78,11 @@ class FileEditor extends ModalComponent
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 = __('common.server_not_found');
@ -91,9 +96,12 @@ class FileEditor extends ModalComponent
$this->loadImage($server, $sftp);
} else {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$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();
@ -133,6 +141,8 @@ class FileEditor extends ModalComponent
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.

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,6 +46,8 @@ 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);

View File

@ -41,6 +41,8 @@ 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;
@ -68,6 +70,8 @@ class HardeningAction extends ModalComponent
public function apply(): void
{
abort_unless(auth()->user()?->can('manage-network'), 403);
if ($this->error !== null || $this->done) {
return;
}
@ -91,8 +95,10 @@ 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']) ?: __('modals.hardening_action.error_unknown'), 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(),

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

@ -31,6 +31,10 @@ class SshKeyProvision extends ModalComponent
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 ?? '';
}
@ -42,6 +46,9 @@ class SshKeyProvision extends ModalComponent
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;
}
@ -59,7 +66,8 @@ class SshKeyProvision extends ModalComponent
$this->done = true;
$this->ok = false;
$this->error = $e->getMessage();
$this->dispatch('hardeningApplied');
// 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'));
@ -72,7 +80,6 @@ class SshKeyProvision extends ModalComponent
$this->publicKey = $result['publicKey'] ?? null;
$this->error = $result['ok'] ? null : ($result['error'] ?? __('modals.ssh_key_provision.error_unknown'));
$this->dispatch('hardeningApplied');
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: $this->ok

View File

@ -42,6 +42,9 @@ 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);
@ -69,6 +72,9 @@ 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;
}

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

@ -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,9 +3,11 @@
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\Url;
use Livewire\Component;
#[Layout('layouts.app')]
@ -14,6 +16,10 @@ 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
{
@ -31,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}%")
@ -48,6 +58,8 @@ 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),

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

@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
@ -24,6 +25,10 @@ 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
{
@ -156,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();
}
@ -165,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;
@ -197,6 +208,10 @@ class Show extends Component
#[On('keyRemoved')]
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) {
@ -253,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;
@ -280,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;
@ -309,6 +328,10 @@ class Show extends Component
#[On('credentialDeleted')]
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) {
@ -327,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 = [];
}
}
/**
@ -340,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;
@ -371,6 +430,10 @@ class Show extends Component
#[On('firewallRuleDelete')]
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) {
@ -431,6 +494,8 @@ 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) {
@ -452,6 +517,8 @@ class Show extends Component
/** 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;
@ -485,6 +552,8 @@ class Show extends Component
/** 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) {

View File

@ -3,10 +3,10 @@
namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
@ -105,6 +105,8 @@ class Index extends Component
*/
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;
@ -140,6 +142,8 @@ class Index extends Component
#[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) {
@ -162,9 +166,27 @@ class Index extends Component
return;
}
$this->dispatch('notify', message: $res['ok']
? __('services.action_done', ['name' => $name, 'op' => $op])
: __('services.action_failed', ['name' => $name, 'output' => Str::limit($res['output'] ?: __('services.no_permission'), 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'];

View File

@ -44,6 +44,7 @@ class Email extends Component
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', '') ?? '';
@ -68,8 +69,23 @@ class Email extends Component
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);
@ -97,6 +113,38 @@ class Email extends Component
$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
@ -104,6 +152,8 @@ class Email extends Component
*/
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')]));
@ -120,6 +170,17 @@ class Email extends Component
}
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 {
@ -150,13 +211,20 @@ class Email extends Component
*/
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 = '';
$stored = Setting::get('mail_password');
if ($stored !== null && $stored !== '') {
try {
$password = Crypt::decryptString($stored);
} catch (Throwable) {
$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 = '';
}
}
}

View File

@ -28,6 +28,7 @@ class LoginProtection extends Component
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();
@ -37,6 +38,7 @@ class LoginProtection extends Component
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'],
@ -72,6 +74,7 @@ class LoginProtection extends Component
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)) {
@ -82,6 +85,7 @@ class LoginProtection extends Component
public function confirmUnban(string $ip): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -99,6 +103,7 @@ class LoginProtection extends Component
#[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) {
@ -111,6 +116,7 @@ class LoginProtection extends Component
public function confirmUnbanAll(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -128,6 +134,7 @@ class LoginProtection extends Component
#[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) {
@ -156,6 +163,8 @@ class LoginProtection extends Component
'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

@ -54,7 +54,7 @@ class Profile extends Component
try {
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
'password' => ['required', 'confirmed', Password::min(6)],
]);
} catch (ValidationException $e) {
if (array_key_exists('current_password', $e->errors())) {

View File

@ -57,6 +57,7 @@ class Sessions extends Component
*/
public function confirmLogoutAll(): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
@ -81,6 +82,7 @@ class Sessions extends Component
#[On('sessionsLogoutAll')]
public function logoutAll(string $confirmToken, SessionService $sessions)
{
abort_unless(auth()->user()?->can('manage-users'), 403);
try {
ConfirmToken::consume($confirmToken, 'sessionsLogoutAll');
} catch (InvalidConfirmToken) {

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Settings;
use App\Enums\Role;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
@ -13,11 +14,15 @@ use Livewire\Attributes\On;
use Livewire\Component;
/**
* Multi-user account management. Every account is a full administrator there are
* no roles. Destructive actions (remove, force-logout) go through the R5 confirm
* modal, which writes one AuditEvent and re-dispatches the apply event back here
* 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
@ -25,6 +30,8 @@ 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');
}
@ -35,6 +42,8 @@ class Users extends Component
*/
public function confirmRemove(int $userId): void
{
abort_unless(auth()->user()?->can('manage-users'), 403);
$user = User::find($userId);
if (! $user || ! $this->canRemove($user)) {
return;
@ -62,6 +71,8 @@ class Users extends Component
#[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) {
@ -74,6 +85,8 @@ class Users extends Component
// 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;
@ -84,6 +97,14 @@ class Users extends Component
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
@ -99,6 +120,12 @@ class Users extends Component
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'));
}
@ -109,6 +136,8 @@ class Users extends Component
*/
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;
@ -135,6 +164,8 @@ class Users extends Component
#[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) {
@ -153,6 +184,103 @@ class Users extends Component
$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
@ -161,9 +289,10 @@ class Users extends Component
}
/**
* A user may be removed unless it is the current operator or the last
* remaining account (no lock-out). Mirrors the create-user "equal admins"
* model anyone can remove anyone else.
* 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
{

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

@ -3,7 +3,6 @@
namespace App\Livewire\System;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
@ -14,23 +13,17 @@ use Livewire\Attributes\On;
use Livewire\Component;
/**
* System settings dashboard-configurable Domain + TLS and the release channel.
* System settings dashboard-configurable Domain + TLS.
*
* 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.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
@ -46,19 +39,8 @@ class Index extends Component
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
@ -67,9 +49,6 @@ class Index extends Component
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$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 = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
@ -83,6 +62,8 @@ class Index extends Component
/** 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'),
@ -118,6 +99,10 @@ class Index extends Component
#[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) {
@ -145,6 +130,8 @@ class Index extends Component
*/
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');
@ -163,6 +150,8 @@ class Index extends Component
*/
public function requestCertificate(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$domain = $deployment->domain();
if ($domain === null || $deployment->externalTls()) {
@ -201,53 +190,11 @@ class Index extends Component
$this->dispatch('notify', message: $message, level: $level);
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
'token' => ConfirmToken::issue(
'channelChanged',
['channel' => $channel],
'system.settings_updated',
'release_channel='.$channel,
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $confirmToken): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$channel = $payload['params']['channel'];
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
/** 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;
}
@ -274,6 +221,10 @@ class Index extends Component
#[On('tlsModeChanged')]
public function applyTlsMode(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, 'tlsModeChanged');
} catch (InvalidConfirmToken) {
@ -306,7 +257,6 @@ class Index extends Component
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'isOverridden' => $deployment->domainIsOverridden(),
'channels' => $this->channelDescriptions(),
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
'certStatus' => $deployment->certStatus(),

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

@ -3,11 +3,10 @@
namespace App\Livewire\Versions;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Services\ReleaseChecker;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
@ -25,9 +24,6 @@ use Livewire\Component;
#[Layout('layouts.app')]
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
@ -186,6 +182,8 @@ class Index extends Component
*/
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);
@ -255,9 +253,7 @@ class Index extends Component
/** 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();
}
/**
@ -269,58 +265,17 @@ class Index extends Component
*/
private function resolveLatestTag(string $channel, bool $forceRemote = false): ?string
{
$key = 'clusev:latest-release:'.$channel;
$checker = app(ReleaseChecker::class);
if ($forceRemote) {
$remote = $this->fetchRemoteLatestTag($channel);
$remote = $checker->refresh($channel);
if ($remote !== null) {
Cache::put($key, $remote, now()->addMinutes(30));
return $remote;
}
// Remote unreachable — fall through to whatever we last cached / can read locally.
}
$cached = Cache::get($key);
if (is_string($cached) && $cached !== '') {
return $cached;
}
return $this->localLatestTag($channel);
}
/**
* Latest tag from the public Git host's API (Gitea: /api/v1/repos/<owner>/<repo>/tags).
* Anonymous + read-only (the repo is public) no token ever leaves the panel. Returns
* null on any failure (offline, non-2xx, malformed) so the caller degrades gracefully.
*/
private function fetchRemoteLatestTag(string $channel): ?string
{
$repo = (string) config('clusev.repository');
if (! preg_match('#^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?/?$#', $repo, $m)) {
return null;
}
[, $base, $owner, $name] = $m;
try {
$res = Http::timeout(5)->acceptJson()
->get("{$base}/api/v1/repos/{$owner}/{$name}/tags", ['limit' => 50]);
if (! $res->successful()) {
return null;
}
$names = array_map(
static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '',
(array) $res->json(),
);
return $this->newestVersion($names, $channel);
} catch (\Throwable $e) {
report($e);
return null;
}
return $checker->cachedLatest($channel) ?? $this->localLatestTag($channel);
}
/**
@ -348,37 +303,7 @@ class Index extends Component
}
}
return $this->newestVersion($tags, $channel);
}
/**
* Pick the newest semantic version from a list of tag names, scoped to the channel:
* 'stable' accepts only plain vX.Y.Z; 'beta' also accepts prereleases (vX.Y.Z-), so a
* beta user is still offered a newer stable. Leading "v" is normalised off the result.
*
* @param array<int, string> $tagNames
*/
private function newestVersion(array $tagNames, string $channel): ?string
{
$pattern = $channel === 'beta'
? '/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/'
: '/^v?\d+\.\d+\.\d+$/';
$versions = [];
foreach (array_unique($tagNames) as $tag) {
$tag = trim((string) $tag);
if ($tag !== '' && preg_match($pattern, $tag)) {
$versions[] = ltrim($tag, 'vV');
}
}
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? */
@ -459,7 +384,10 @@ class Index extends Component
if ($rel['unreleased']) {
$key = 'unreleased';
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
$key = $m[1].'.'.$m[2];
// 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';
}
@ -474,7 +402,7 @@ class Index extends Component
'label' => match ($key) {
'unreleased' => __('versions.unreleased'),
'other' => __('versions.series_other'),
default => $key,
default => ltrim($key, 'v'), // display the dotted series (the 'v' is only for the URL)
},
'unreleased' => $key === 'unreleased',
'count' => count($rels),
@ -492,7 +420,7 @@ class Index extends Component
return $ra <=> $rb;
}
return $ra === 1 ? version_compare($b['key'].'.0', $a['key'].'.0') : 0;
return $ra === 1 ? version_compare(ltrim($b['key'], 'v').'.0', ltrim($a['key'], 'v').'.0') : 0;
});
return $series;
@ -542,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) {
@ -599,6 +538,76 @@ 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();
@ -624,7 +633,7 @@ class Index extends Component
// 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) ? $m[1].'.'.$m[2] : null;
$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());
@ -638,7 +647,7 @@ class Index extends Component
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(),
'seriesList' => $seriesList,
@ -651,6 +660,10 @@ class Index extends Component
'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

@ -3,16 +3,20 @@
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
@ -21,10 +25,17 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
public int $window = 3600;
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 = '';
@ -33,6 +44,8 @@ class Index extends Component
public string $newSubnet = '';
public string $newDns = '';
// first-time setup form
public string $setupSubnet = '10.99.0.0/24';
@ -55,14 +68,37 @@ class Index extends Component
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 = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
$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
{
$this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
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'),
]);
@ -72,24 +108,27 @@ class Index extends Component
$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:/^[A-Za-z0-9._-]{1,64}$/'],
'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 ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) {
if (! $this->portInRange($this->setupPort)) {
$this->addError('setupPort', __('wireguard.port_invalid'));
return;
@ -102,36 +141,30 @@ class Index extends Component
'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('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
if (preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('wireguard.remove_confirm_title'),
'body' => __('wireguard.remove_confirm_body'),
'confirmLabel' => __('wireguard.remove'),
'danger' => true,
'icon' => 'trash',
'notify' => '',
// No audit descriptor — the apply path (removePeer) audits, so the confirm modal
// must NOT also audit (avoids a double audit row for one action).
'token' => ConfirmToken::issue('wgPeerRemoved', ['name' => $name]),
],
);
$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) {
@ -139,7 +172,7 @@ class Index extends Component
}
$name = $payload['params']['name'] ?? '';
if ($name === '' || preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
if ($name === '' || preg_match(self::PEER_NAME_RE, $name) !== 1) {
return;
}
@ -148,7 +181,9 @@ class Index extends Component
public function removePeer(WgBridge $bridge, string $name): void
{
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
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]);
@ -158,6 +193,8 @@ class Index extends Component
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'),
@ -171,36 +208,51 @@ class Index extends Component
$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->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
'body' => $on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
'confirmLabel' => $on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'),
'danger' => ! $on,
'icon' => 'shield',
'notify' => '',
'token' => ConfirmToken::issue('wgGateToggle', ['on' => $on ? '1' : '0']),
],
);
$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(($payload['params']['on'] ?? '0') === '1', $bridge);
$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;
}
@ -210,35 +262,69 @@ class Index extends Component
$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 ((int) $this->newPort < 1 || (int) $this->newPort > 65535) {
if (! $this->portInRange($this->newPort)) {
$this->addError('newPort', __('wireguard.port_invalid'));
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('wireguard.port_confirm_title'),
'body' => __('wireguard.port_confirm_body'),
'confirmLabel' => __('wireguard.apply'),
'danger' => true,
'icon' => 'alert',
'notify' => '',
'token' => ConfirmToken::issue('wgSetPort', ['port' => $this->newPort]),
],
);
$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) {
@ -262,23 +348,16 @@ class Index extends Component
['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')],
);
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('wireguard.subnet_confirm_title'),
'body' => __('wireguard.subnet_confirm_body'),
'confirmLabel' => __('wireguard.apply'),
'danger' => true,
'icon' => 'alert',
'notify' => '',
'token' => ConfirmToken::issue('wgSetSubnet', ['subnet' => $this->newSubnet]),
],
);
$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) {
@ -319,19 +398,87 @@ class Index extends Component
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
@ -360,11 +507,23 @@ class Index extends Component
public function render(WgStatus $wg, WgTraffic $traffic)
{
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
$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' => $traffic->series($window),
'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));
}
}

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'));
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class ServerGroup extends Model
{
protected $guarded = [];
/** Allowed @theme colour token keys for a group's dot/pill (R3 — never a raw hex). */
public const COLORS = ['accent', 'cyan', 'online', 'warning', 'offline', 'ink'];
protected static function booted(): void
{
static::creating(fn (self $g) => $g->uuid ??= (string) Str::uuid());
// Fall back to the brand colour if an out-of-allow-list token slips in (defence in depth;
// the component validates too — this guarantees the token is always a real @theme colour).
static::saving(function (self $g): void {
if (! in_array($g->color, self::COLORS, true)) {
$g->color = 'accent';
}
});
}
// R11: addressed by uuid in URLs, never the bigint id.
public function getRouteKeyName(): string
{
return 'uuid';
}
public function servers(): BelongsToMany
{
return $this->belongsToMany(Server::class);
}
}

View File

@ -26,9 +26,27 @@ class Setting extends Model
return $value ?? $default;
}
/**
* Read a setting DIRECTLY from the DB, bypassing the read-through cache. For SECURITY-SENSITIVE
* values (e.g. an alert token) where a concurrent cache-miss could re-populate a STALE value just
* after a change and pair an old secret with a new target the cache-aside race the plain get()
* is exposed to. Costs one query; use only where staleness is a correctness/security bug.
*/
public static function uncached(string $key, ?string $default = null): ?string
{
return static::query()->find($key)?->value ?? $default;
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget("setting:{$key}");
}
/** Remove a setting entirely (so get() falls back to its default). */
public static function forget(string $key): void
{
static::query()->whereKey($key)->delete();
Cache::forget("setting:{$key}");
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A single-use, short-lived handle the browser passes to the terminal sidecar; the sidecar exchanges
* it (over the internal network, authenticated by a shared secret) for a connection spec. Burned on
* first resolve. Never holds credentials only points at the user + target.
*/
class TerminalSession extends Model
{
public const UPDATED_AT = null;
/** Explicit allowlist (never $guarded=[]) so request input can't set unintended columns. */
protected $fillable = ['token', 'user_id', 'kind', 'server_id', 'expires_at', 'used_at', 'created_at'];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
'created_at' => 'datetime',
];
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\Role;
use App\Notifications\QueuedResetPassword;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@ -15,7 +16,7 @@ use Illuminate\Support\Str;
use PragmaRX\Google2FA\Exceptions\Google2FAException;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale', 'role'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
@ -31,6 +32,8 @@ class User extends Authenticatable
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
'must_change_password' => 'boolean',
'onboarding_tour_completed_at' => 'datetime',
'role' => Role::class,
];
}
@ -143,6 +146,18 @@ class User extends Authenticatable
return ! $this->must_change_password;
}
/** True when the account is a full administrator. */
public function isAdmin(): bool
{
return $this->role === Role::Admin;
}
/** True when the account's role is at least $role in the admin>operator>viewer hierarchy. */
public function roleAtLeast(Role $role): bool
{
return $this->role?->atLeast($role) ?? false;
}
/**
* When neither factor remains (no TOTP, no keys), 2FA is fully off so drop the backup
* codes (nothing left to recover into). Call after removing any factor.

View File

@ -2,18 +2,20 @@
namespace App\Providers;
use App\Enums\Role;
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Models\Setting;
use App\Models\User;
use App\Services\DeploymentService;
use App\Support\MailSettings;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
@ -52,6 +54,14 @@ class AppServiceProvider extends ServiceProvider
EnsureSecurityOnboarded::class,
]);
// RBAC abilities. admin has every dangerous control-plane ability; operator can run
// day-to-day operations; viewer is read-only. Gates are the single source consumed by route
// `can:` middleware, component mount() abort_unless, per-action guards and @can in Blade.
foreach (['manage-panel', 'manage-users', 'manage-network', 'manage-fleet'] as $ability) {
Gate::define($ability, fn (User $u) => $u->isAdmin());
}
Gate::define('operate', fn (User $u) => $u->roleAtLeast(Role::Operator));
// Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint
// (/livewire/update carries every component request, including the auth actions). The
// per-action limiters in the components are the precise gate; this is the blunt cap so a
@ -77,50 +87,12 @@ class AppServiceProvider extends ServiceProvider
config(['app.url' => 'https://'.$domain]);
}
$this->applyMailSettings();
}
/**
* Apply the operator's SMTP settings (Settings\Email) to runtime config so
* password-reset mails and notifications actually send. boot() runs every request,
* so a saved change takes effect on the next request.
*
* Only switches to the `smtp` mailer when host + from-address are configured;
* otherwise the safe install default (`log`) is left untouched, so nothing breaks
* while unconfigured. The stored SMTP password is encrypted at rest (APP_KEY) and
* decrypted here. Wrapped in try/catch like DeploymentService the settings table
* may not exist yet during install/migrate, and that must never break boot.
*/
private function applyMailSettings(): void
{
try {
$host = Setting::get('mail_host');
$from = Setting::get('mail_from_address');
if ($host === null || $host === '' || $from === null || $from === '') {
return; // unconfigured -> keep the safe default mailer
}
$encryption = Setting::get('mail_encryption', 'tls');
$storedPassword = Setting::get('mail_password');
$password = null;
if ($storedPassword !== null && $storedPassword !== '') {
$password = Crypt::decryptString($storedPassword);
}
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.host' => $host,
'mail.mailers.smtp.port' => (int) (Setting::get('mail_port', '587') ?? 587),
'mail.mailers.smtp.username' => Setting::get('mail_username') ?: null,
'mail.mailers.smtp.password' => $password ?: null,
'mail.mailers.smtp.encryption' => $encryption === 'none' ? null : $encryption,
'mail.from.address' => $from,
'mail.from.name' => Setting::get('mail_from_name') ?: $from,
]);
} catch (Throwable) {
// Settings table not migrated yet (install/migrate) or an undecryptable value —
// leave the default mailer untouched. Never break boot.
}
// Operator SMTP settings → runtime mail config. Shared helper (MailSettings::apply) because
// THREE process shapes need it fresh: web requests (here, per request), the queue worker
// (Queue::before, per job — boot() runs only once in that long-lived process), and the
// long-lived metrics poller (AlertNotifier applies it right before queueing, since the
// default MAILER NAME is baked into a mailable at queue time).
MailSettings::apply();
Queue::before(fn () => MailSettings::apply());
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Services;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use Illuminate\Database\QueryException;
/**
* The alert state machine. Given a server's fresh reading, opens a firing incident on a new breach
* (and notifies) and resolves an open incident on recovery (and notifies). Idempotent: a sustained
* breach with an already-open incident does nothing, so one breach = one alert, not a flood.
*/
class AlertEvaluator
{
public function __construct(private AlertNotifier $notifier) {}
/**
* @param array<string, int|string> $metrics cpu/mem/disk/load ints (empty when offline)
* @param string $status the server's fresh status (online|warning|offline)
*/
public function evaluate(Server $server, array $metrics, string $status): void
{
$rules = AlertRule::where('enabled', true)->get();
foreach ($rules as $rule) {
if (! $rule->targets($server)) {
continue;
}
// Numeric metrics need a fresh reading; skip them only when the server is truly OFFLINE
// (stale numbers). A reachable "warning" server still has fresh metrics, so it evaluates.
if ($rule->metric !== 'offline' && $status === 'offline') {
continue;
}
$this->transition($rule, $server, $rule->breached($metrics, $status), $rule->reading($metrics));
}
}
private function transition(AlertRule $rule, Server $server, bool $breached, float $reading): void
{
$open = AlertIncident::where('alert_rule_id', $rule->id)
->where('server_id', $server->id)
->where('state', 'firing')
->first();
if ($breached && ! $open) {
try {
$incident = AlertIncident::create([
'alert_rule_id' => $rule->id,
'server_id' => $server->id,
'state' => 'firing',
'value' => $reading,
// Unique while firing → a concurrent tick that also tries to open this incident
// hits a unique violation instead of creating a duplicate (see the migration).
'firing_key' => $rule->id.':'.$server->id,
'started_at' => now(),
]);
} catch (QueryException) {
return; // another tick opened it first — dedup, no duplicate incident/notification
}
AlertIncident::forgetFiringCount(); // keep the sidebar badge in step with this new incident
$this->notifier->notify($incident, resolved: false);
return;
}
if (! $breached && $open) {
// Clear firing_key on resolve so a later re-breach can claim the unique key again.
$open->update(['state' => 'resolved', 'firing_key' => null, 'resolved_at' => now()]);
AlertIncident::forgetFiringCount();
$this->notifier->notify($open, resolved: true);
}
// else: still firing with an open incident, or fine with none → dedup, no-op.
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace App\Services;
use App\Enums\Role;
use App\Mail\AlertNotification;
use App\Models\AlertIncident;
use App\Models\Setting;
use App\Models\User;
use App\Support\MailSettings;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Fans one alert transition (fire / resolve) out to the configured channels. Every channel is
* BEST-EFFORT: a broken SMTP config or an unreachable webhook is logged, never thrown, so a
* notification failure can never break the poll loop that triggered it.
*/
class AlertNotifier
{
public function notify(AlertIncident $incident, bool $resolved): void
{
// Re-apply the DB SMTP settings RIGHT BEFORE queueing: the metrics poller calling this is a
// long-lived process, and Laravel bakes the current default MAILER NAME into the mailable at
// queue time — without this, a poller that booted before SMTP was configured would bake the
// `log` mailer into every alert e-mail forever (they'd land in laravel.log, never the inbox).
MailSettings::apply();
$this->email($incident, $resolved);
$this->webhooks($incident, $resolved);
$this->gotify($incident, $resolved);
}
/**
* Gotify push (self-hostable push server): POST /message with the app token in the X-Gotify-Key
* header + a {title, message, priority} body the shape Gotify expects (the generic webhook's
* bare `text` field would not render). A firing alert uses a high priority (8) so it pushes/rings;
* a resolve is low (3). Best-effort like every channel. Unlike the SSRF-guarded webhooks a Gotify
* server is COMMONLY on a private LAN, so only the scheme is enforced (isValidGotifyUrl), not the
* private-range block that is the operator's deliberate, admin-only choice.
*/
private function gotify(AlertIncident $incident, bool $resolved): void
{
// Read + validate + decrypt ONCE and send exactly that pair — no re-read of the settings
// between the check and the POST (which could otherwise pair a new URL with an old token).
$target = self::gotifyTarget();
if ($target === null) {
return;
}
$url = $target['url'];
$token = $target['token'];
$server = $incident->server?->name ?? '—';
$rule = $incident->rule?->name ?? '—';
$title = ($resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing'))."{$server}";
try {
Http::timeout(5)->withoutRedirecting()
->withHeaders(['X-Gotify-Key' => $token])
->asJson()
->post(rtrim($url, '/').'/message', [
'title' => $title,
'message' => "{$rule}: {$incident->rule?->metric} = {$incident->value} (Limit {$incident->rule?->threshold})",
'priority' => $resolved ? 3 : 8,
]);
} catch (Throwable $e) {
Log::warning('alert gotify failed: '.$e->getMessage());
}
}
/** http(s) + a parseable host. NO private-range block (a self-hosted Gotify is usually on a LAN). */
public static function isValidGotifyUrl(string $url): bool
{
$p = parse_url(trim($url));
return is_array($p) && isset($p['scheme'], $p['host'])
&& in_array(strtolower($p['scheme']), ['http', 'https'], true);
}
/**
* The validated Gotify target the sender will use ['url' => string, 'token' => plaintext] or
* null when Gotify can't actually send (missing/invalid URL, or a missing/undecryptable token).
* ONE place reads + validates + decrypts, so the "no delivery channel" banner (which checks for
* null) and the send path can never diverge, and the POST uses exactly the pair that was
* validated in the same read no check-then-reread TOCTOU.
*
* @return array{url: string, token: string}|null
*/
public static function gotifyTarget(): ?array
{
// Setting::uncached (DB-direct, cache-bypass): a stale cached token read just after a URL change
// could otherwise pair an old token with a new endpoint (token exfil). Reads the committed
// state, which — with saveChannels dropping the token before writing the new URL — can never
// present a new-url + old-token pair.
$url = trim((string) Setting::uncached('alert_gotify_url', ''));
$stored = (string) Setting::uncached('alert_gotify_token', '');
if ($url === '' || $stored === '' || ! self::isValidGotifyUrl($url)) {
return null;
}
try {
return ['url' => $url, 'token' => Crypt::decryptString($stored)];
} catch (Throwable) {
return null; // undecryptable (rotated APP_KEY) — treat as not configured
}
}
private function email(AlertIncident $incident, bool $resolved): void
{
// ONE message per recipient — NOT a single To: with everyone. A mixed To that contains an
// undeliverable address (e.g. a seeded admin on a non-routable domain) reads as spam to
// providers like Gmail and gets the WHOLE message silently dropped for the valid recipients
// too. Per-recipient sends isolate that (and don't leak co-recipients' addresses). Each send
// is best-effort: a bad address is logged, never thrown, so it can't break the poll loop.
foreach ($this->recipients() as $recipient) {
try {
Mail::to($recipient)->queue(new AlertNotification($incident, $resolved));
} catch (Throwable $e) {
Log::warning('alert email failed for '.$recipient.': '.$e->getMessage());
}
}
}
private function webhooks(AlertIncident $incident, bool $resolved): void
{
$payload = $this->payload($incident, $resolved);
foreach ($this->webhookUrls() as $url) {
try {
// withoutRedirecting: a 30x could otherwise bounce the POST to an internal target that
// the SSRF host-check below never saw. timeout keeps a slow hook from stalling the loop.
Http::timeout(5)->withoutRedirecting()->asJson()->post($url, $payload);
} catch (Throwable $e) {
Log::warning('alert webhook failed: '.$e->getMessage());
}
}
}
/**
* SSRF guard for an operator-configured webhook URL: exactly http(s), and the host must NOT
* resolve to a loopback/private/link-local/reserved address (so a hook can't be pointed at an
* internal service). Admin-only config, but defence in depth checked here AND at save time.
*/
public static function isSafeWebhookUrl(string $url): bool
{
$parts = parse_url(trim($url));
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
return false;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
// IP literal → itself; hostname → its A records (IPv4). Unresolvable / IPv6-only → refuse.
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
if ($ips === []) {
return false;
}
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false; // loopback/private/link-local/reserved → blocked
}
}
return true;
}
/** @return list<string> */
private function recipients(): array
{
$configured = $this->splitLines((string) Setting::get('alert_email_to', ''));
$emails = array_values(array_filter($configured, fn (string $e) => filter_var($e, FILTER_VALIDATE_EMAIL) !== false));
if ($emails !== []) {
return $emails;
}
// Fall back to every admin's e-mail so a fresh install still alerts someone.
return User::query()->where('role', Role::Admin->value)
->whereNotNull('email')
->pluck('email')->all();
}
/** @return list<string> */
private function webhookUrls(): array
{
return array_values(array_filter(
$this->splitLines((string) Setting::get('alert_webhooks', '')),
fn (string $u) => self::isSafeWebhookUrl($u),
));
}
/** @return array<string, mixed> */
private function payload(AlertIncident $incident, bool $resolved): array
{
$server = $incident->server?->name ?? '—';
$rule = $incident->rule?->name ?? '—';
$state = $resolved ? 'resolved' : 'firing';
$text = $resolved
? __('alerts.mail_subject_resolved')."{$rule} ({$server})"
: __('alerts.mail_subject_firing')."{$rule} ({$server})";
// A single `text` field satisfies Slack/Discord/Mattermost/Telegram-relay incoming webhooks;
// the structured fields let a custom consumer route on server/metric/state.
return [
'text' => $text,
'state' => $state,
'server' => $server,
'rule' => $rule,
'metric' => $incident->rule?->metric,
'value' => $incident->value,
'threshold' => $incident->rule?->threshold,
];
}
/** @return list<string> */
private function splitLines(string $raw): array
{
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
}

View File

@ -118,6 +118,69 @@ class BruteforceGuard
]);
}
/**
* Ban an IP on the FIRST hit no maxretry threshold, no RateLimiter window. Used by the
* honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
* hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
* throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the
* trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a
* disabled brute-force protection must not silently disable honeypot bans). Returns true when a
* ban was written, false when it no-op'd (invalid / exempt IP) the caller stays a cheap no-op
* for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry).
*/
public function banNow(?string $ip, string $reason): bool
{
if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
return false;
}
$canonical = $this->canonical($ip);
$now = now();
BannedIp::upsert(
[[
'ip' => $canonical,
'banned_until' => $now->copy()->addMinutes($this->bantime()),
'reason' => $reason,
'attempts' => $this->maxretry(),
'created_at' => $now,
'updated_at' => $now,
]],
['ip'],
['banned_until', 'reason', 'attempts', 'updated_at'],
);
Cache::forget('bruteforce:banned:'.$canonical);
// Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober
// looping the fake login would write one auth.ip_banned row per request. Emit the audit at most
// once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key
// includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a
// honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the
// rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban,
// which is minutes of human time later, well past this window (so it is not tied to unban()).
$auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason;
if (Cache::add($auditKey, 1, 60)) {
try {
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'auth.ip_banned',
'target' => $canonical,
'ip' => $canonical,
'meta' => ['reason' => $reason],
]);
} catch (\Throwable $e) {
// The Cache::add above atomically CLAIMED the dedup slot before the write. If the
// write throws (a transient DB blip), release the slot so the 60s window is not
// burned with no audit row — the next attempt re-claims and re-audits this ban.
Cache::forget($auditKey);
throw $e;
}
}
return true;
}
/**
* Whether $ip has an active ban. Does NOT check enabled() the BlockBannedIp middleware
* gates on enabled() before calling this, so disabling the feature stops enforcement while

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services;
/**
* Reads a TLS endpoint's certificate expiry natively (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 point is to report expiry even for a self-signed or already-expired cert.
* Monitoring internal endpoints is a legitimate use, so private/internal hosts are allowed.
*/
class CertService
{
/**
* @return array{ok:bool, subject?:string, issuer?:string, expiresAt?:int, daysLeft?:?int, error?:string}
*/
public function check(string $host, int $port, int $timeout = 5): array
{
$ctx = stream_context_create(['ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
'SNI_enabled' => true,
'peer_name' => $host,
]]);
$errno = 0;
$errstr = '';
// @ — a refused/timed-out/unresolved endpoint is a normal result here, not a PHP warning.
$client = @stream_socket_client(
'ssl://'.$host.':'.$port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx,
);
if (! $client) {
return ['ok' => false, 'error' => $errstr !== '' ? $errstr : 'connection failed'];
}
$params = stream_context_get_params($client);
fclose($client);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if ($cert === null) {
return ['ok' => false, 'error' => 'no certificate presented'];
}
return ['ok' => true] + $this->certInfo($cert);
}
/**
* Extract subject/issuer/expiry from an openssl cert (resource or PEM). Split out so it is unit-
* testable against a generated cert without a live network connection.
*
* @param \OpenSSLCertificate|string $cert
* @return array{subject:string, issuer:string, expiresAt:?int, daysLeft:?int}
*/
public function certInfo($cert): array
{
$parsed = openssl_x509_parse($cert) ?: [];
$expiresAt = isset($parsed['validTo_time_t']) ? (int) $parsed['validTo_time_t'] : null;
return [
'subject' => (string) ($parsed['subject']['CN'] ?? ($parsed['subject']['O'] ?? '—')),
'issuer' => (string) ($parsed['issuer']['CN'] ?? ($parsed['issuer']['O'] ?? '—')),
'expiresAt' => $expiresAt,
'daysLeft' => $expiresAt !== null ? (int) floor(($expiresAt - time()) / 86400) : null,
];
}
/** expired · critical (<7d) · warning (<30d) · ok — mapped to the status triad in the UI. */
public function status(?int $daysLeft): string
{
if ($daysLeft === null) {
return 'unknown';
}
return match (true) {
$daysLeft < 0 => 'expired',
$daysLeft < 7 => 'critical',
$daysLeft < 30 => 'warning',
default => 'ok',
};
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Models\Server;
use Illuminate\Support\Collection;
use Throwable;
/**
* Runs one (arbitrary, user-supplied) command across a set of servers and collects a per-server
* result. The command is INTENTIONALLY arbitrary it runs via FleetService::runPlain (the
* credential's login user, base64 transport, no extra sudo) exactly as the web terminal would.
* One server failing (connect/exec) never aborts the batch. Remote output is byte-capped +
* mb_scrub'd so it can safely land in a Livewire property.
*/
class CommandRunner
{
private const MAX_BYTES = 262144; // 256 KiB per server
public function __construct(private FleetService $fleet) {}
/**
* @param Collection<int, Server> $servers
* @return array<int, array{server:string, ok:bool, output:string}>
*/
public function run(string $command, Collection $servers, int $timeout = 60): array
{
$results = [];
foreach ($servers as $server) {
try {
$res = $this->fleet->runPlain($server, $command, $timeout);
$results[] = ['server' => $server->name, 'ok' => (bool) $res['ok'], 'output' => $this->clean($res['output'])];
} catch (Throwable $e) {
$results[] = ['server' => $server->name, 'ok' => false, 'output' => $this->clean($e->getMessage())];
}
}
return $results;
}
private function clean(string $output): string
{
return mb_scrub(mb_strcut($output, 0, self::MAX_BYTES), 'UTF-8');
}
}

View File

@ -52,6 +52,20 @@ class DeploymentService
*/
private const UPDATE_FILE = 'app/restart-signal/update.request';
/**
* Update start marker written (best-effort) when an update is requested, on the same
* bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase
* checklist are anchored to the SERVER-side start, not to page-load time a browser refresh
* mid-update no longer resets the timer to zero nor drops the checklist back to phase one.
*/
private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json';
/**
* Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses
* (fetch build restart migrate done). Same bind-mounted directory. Read-only token.
*/
private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json';
/**
* The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
* (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
@ -209,6 +223,58 @@ class DeploymentService
return storage_path(self::UPDATE_FILE);
}
/** Absolute path of the update-start marker on the shared (bind-mounted) volume. */
public function updateStartedPath(): string
{
return storage_path(self::UPDATE_STARTED_FILE);
}
/**
* Epoch seconds when the current update was requested, or null if there is no readable marker.
* The update-progress page anchors its elapsed timer and live phase feed to this so both
* survive a browser refresh instead of resetting on every page load. Never throws.
*/
public function updateStartedAt(): ?int
{
$path = $this->updateStartedPath();
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
$at = is_array($data) ? ($data['at'] ?? null) : null;
return is_int($at) && $at > 0 ? $at : null;
}
/**
* The current update macro-stage written by the host updater, or null when absent/unrecognised.
* Only ever returns a whitelisted stage token (never arbitrary file content). Shape:
* ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => <epoch>]. Never throws.
*
* @return array{stage: string, at: int}|null
*/
public function updatePhase(): ?array
{
$path = storage_path(self::UPDATE_PHASE_FILE);
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done', 'error'], true)) {
return null;
}
// Require a real timestamp: the progress page's freshness logic ties a stage to THIS update
// via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0`
// fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a
// premature completion redirect. Treat it as "no stage" and let the page fall back.
$at = $data['at'] ?? null;
if (! is_int($at) || $at <= 0) {
return null;
}
return ['stage' => (string) $data['stage'], 'at' => $at];
}
/**
* Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped
* clusev-update.path unit) reacts by running update.sh git pull + idempotent re-install
@ -224,9 +290,31 @@ class DeploymentService
@mkdir($dir, 0775, true);
}
// Sign the marker so the host updater (watch.sh) can reject a forged/limited write to the
// shared ./run dir before running a root update: line 1 = timestamp, line 2 = its HMAC.
// Falls back to the bare timestamp when no key is configured (dev / pre-HMAC installs), which
// the host accepts too.
$ts = now()->toIso8601String();
$key = (string) config('clusev.update_hmac_key');
$body = $key !== ''
? $ts.PHP_EOL.hash_hmac('sha256', $ts, $key).PHP_EOL
: $ts.PHP_EOL;
// Returns false when the (bind-mounted) sentinel dir is not writable by the app user —
// the caller surfaces that instead of showing a "running" state that never resolves.
return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
$written = @file_put_contents($path, $body) !== false;
if ($written) {
// Best-effort start marker (a failure here must never fail the update): lets the
// progress page render a refresh-proof elapsed time and accept the live phase feed
// regardless of how long the current stage has been running.
@file_put_contents($this->updateStartedPath(), json_encode([
'at' => now()->timestamp,
'from' => (string) config('clusev.version'),
]).PHP_EOL);
}
return $written;
}
/** True while an update request is pending (the host watcher clears it once handled). */
@ -235,13 +323,14 @@ class DeploymentService
return is_file($this->updateSignalPath());
}
/** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */
/** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes
* the sentinel normally). */
public function clearUpdateRequest(): void
{
$path = $this->updateSignalPath();
if (is_file($path)) {
@unlink($path);
foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) {
if (is_file($path)) {
@unlink($path);
}
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Services;
use App\Exceptions\DockerNotInstalled;
use App\Models\Server;
use InvalidArgumentException;
use RuntimeException;
/**
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
* through FleetService::runPrivileged (base64 transport shell-injection-safe); every container
* id/name is validated before interpolation so a crafted name can neither inject a command nor a
* leading-dash argument. Mirrors the FleetService::serviceAction discipline.
*/
class DockerService
{
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'];
/**
* A non-interactive SSH exec (and sudo's secure_path) often has a MINIMAL PATH (~/usr/bin:/bin),
* so `docker` installed in /usr/local/bin (get.docker.com), /snap/bin (snap), or /usr/sbin isn't
* found "sh: docker: not found" even though it IS installed. Prepend the common bin dirs to
* every docker call so the binary is located regardless of the login shell's PATH. Single-quoted
* so `${PATH:+:$PATH}` reaches the remote shell literally (not interpolated by PHP).
*/
private const PATH_PREFIX = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin${PATH:+:$PATH}; ';
public function __construct(private FleetService $fleet) {}
public function available(Server $server): bool
{
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'command -v docker >/dev/null 2>&1 && echo yes || echo no');
return trim($res['output']) === 'yes';
}
/**
* @return array<int, array{id:string,name:string,image:string,state:string,status:string,ports:string}>
*/
public function containers(Server $server): array
{
// Tab-separated columns (NOT `{{json .}}`): works on much older Docker AND avoids a JSON
// key-casing dependency. `.State` was added late, so we derive state from `.Status` text —
// this is the whole reason a modern-only `{{json .}}` could return nothing on an older host.
$fmt = '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}';
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'");
if (! $res['ok']) {
$err = $this->firstLine($res['output']);
// Distinguish "docker binary absent" (a native server, no runtime) from a real fault
// right here, so callers get the honest "not installed" state from THIS single `docker
// ps` call instead of paying a second SSH round-trip on a separate available() probe.
if (preg_match('/docker:\s.*not found/i', $err)) {
throw new DockerNotInstalled($err);
}
// Surface the REAL reason (daemon down / permission / rootless socket) rather than a
// misleading empty list — the component shows this instead of "no containers".
throw new RuntimeException($err ?: 'docker ps failed');
}
$out = [];
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$p = explode("\t", $line);
$status = trim($p[3] ?? '');
$name = trim($p[1] ?? '');
$out[] = [
'id' => trim($p[0] ?? ''),
'name' => $name,
'display' => self::displayName($name),
'image' => trim($p[2] ?? ''),
'state' => $this->stateFromStatus($status),
'status' => $status,
'ports' => trim($p[4] ?? ''),
];
}
return $out;
}
/**
* Display-only container name: strip the compose replica suffix ("clusev-app-1" -> "clusev-app").
* NEVER use for docker commands actions/logs keep the real name/id.
*/
public static function displayName(string $name): string
{
return preg_replace('/-\d+$/', '', $name) ?: $name;
}
/** Derive a coarse state from docker's Status text (portable across versions that lack .State). */
private function stateFromStatus(string $status): string
{
$s = strtolower($status);
return match (true) {
str_contains($s, '(paused)') => 'paused',
str_starts_with($s, 'up') => 'running',
str_starts_with($s, 'created') => 'created',
str_starts_with($s, 'restarting') => 'restarting',
default => 'exited', // Exited / Dead / Removal
};
}
private function firstLine(string $out): string
{
return trim(preg_split('/\r?\n/', trim($out))[0] ?? '');
}
public function containerAction(Server $server, string $id, string $op): array
{
if (! in_array($op, self::ACTIONS, true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->assertValidRef($id);
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker {$op} {$id}");
return ['ok' => $res['ok'], 'output' => trim($res['output'])];
}
public function logs(Server $server, string $id, int $lines = 200): string
{
$this->assertValidRef($id);
$lines = max(1, min(2000, $lines));
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat
// the response or, via invalid UTF-8, break Livewire's JSON snapshot. head -c bounds it, and
// mb_scrub drops any invalid byte sequence before it reaches a Livewire public property.
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker logs --tail {$lines} {$id} 2>&1 | head -c 262144");
return mb_scrub(trim($res['output']), 'UTF-8');
}
/**
* @return array<int, array{name:string,status:string}>
*/
public function composeStacks(Server $server): array
{
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'docker compose ls --format json 2>/dev/null');
if (! $res['ok']) {
return [];
}
$rows = json_decode(trim($res['output']), true);
if (! is_array($rows)) {
return [];
}
return array_map(fn ($r) => [
'name' => (string) ($r['Name'] ?? ''),
'status' => (string) ($r['Status'] ?? ''),
], array_values(array_filter($rows, 'is_array')));
}
/**
* A container id or name hex id or a docker name. MUST start alphanumeric (blocks a leading
* dash = argument injection) and contain only [\w.-] (no shell metacharacters).
*/
private function assertValidRef(string $ref): void
{
// \A ... \z (not ^...$): \z anchors the ABSOLUTE end so a trailing newline can't sneak past.
if (! preg_match('/\A[a-zA-Z0-9][\w.-]*\z/', $ref)) {
throw new InvalidArgumentException('Ungueltige Container-Referenz.');
}
}
}

View File

@ -398,7 +398,12 @@ class Fail2banService
private function validJail(string $jail): bool
{
return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail);
// Must START with an alphanumeric/underscore: a leading '-' would be parsed by
// fail2ban-client as an OPTION (e.g. "-h", "--help") rather than a jail argument,
// and a leading '.' has no legitimate jail use. Internal '.-_' stay allowed so real
// names like "nginx-http-auth" pass. (Shell metacharacters are already inert via the
// base64 transport in runPrivileged; this closes the argument-injection confusion.)
return (bool) preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]*$/', $jail);
}
private function validIpOrCidr(string $v): bool

View File

@ -561,7 +561,9 @@ class FleetService
$content = $size > $maxBytes ? '' : (string) $sftp->get($path);
$binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8'));
return ['content' => $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
// Never hand back raw non-UTF-8 bytes: bound to a Livewire public property they break the
// JSON snapshot ("undefined is not valid JSON"). The caller renders a binary notice instead.
return ['content' => $binary ? '' : $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
} finally {
$sftp->disconnect();
}
@ -604,18 +606,47 @@ class FleetService
if (! in_array($op, ['start', 'stop', 'restart'], true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
if (! preg_match('/^[\w@.:-]+$/', $unit)) {
// Unit MUST start alphanumeric (blocks a leading-dash value that systemctl would parse as an
// option, e.g. `-H host` / `--signal=`) — mirrors the DockerService::ref() discipline. Every
// systemctl call below ALSO passes the unit after `--` (end-of-options) and escapeshellarg'd,
// so a hostile unit can never become a flag even if the pattern were ever loosened.
if (! preg_match('/\A[a-zA-Z0-9][\w@.:-]*\z/', $unit)) {
throw new InvalidArgumentException('Ungueltige Unit.');
}
$safe = escapeshellarg($unit);
$ssh = $this->client($server);
try {
[$out, $code] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1"
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} -- {$safe} 2>&1"
);
$out = trim($out);
$ok = $code === 0;
return ['ok' => $code === 0, 'output' => trim($out)];
// A `start`/`restart` can EXIT 0 (systemd accepted the request) yet the unit dies on
// launch → is-active=failed a moment later (e.g. monit crashing on a bad config). Verify
// the real post-action state; if it never came up, report failure WITH the reason (last
// journal lines) so the operator sees *why* — not a false "gestartet". (stop = the unit
// going inactive IS success, so skip the check there.)
if ($ok && $op !== 'stop') {
[$state] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl is-active -- {$safe} 2>&1"
);
$state = trim($state);
if ($state !== 'active') {
$ok = false;
[$why] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl status --no-pager --lines=12 -- {$safe} 2>&1 || true"
);
$detail = trim($why);
$out = trim(($out !== '' ? $out."\n" : '')
.$unit.': '.$state.' ('.$op.')'
.($detail !== '' ? "\n".$detail : ''));
}
}
return ['ok' => $ok, 'output' => $out];
} finally {
$ssh->disconnect();
}
@ -718,7 +749,7 @@ class FleetService
{
$ips = [];
foreach ($this->lines($addr) as $line) {
// "2: ens18 inet 10.10.90.162/24 brd ..."
// "2: ens18 inet 203.0.113.11/24 brd ..."
if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) {
$ips[$m[1]][] = $m[2];
}

View File

@ -206,6 +206,13 @@ class HardeningService
return ['ok' => $res['ok'], 'output' => $res['output']];
}
// Lock-out guard: disabling root login (PermitRootLogin no) severs Clusev's OWN access when
// it connects AS root — no key or password can reach root afterwards. Refuse until Clusev is
// switched to a non-root credential (a sudo user with a key), then root login can be disabled.
if ($action === 'ssh_root' && ! $enable && $server->credential?->username === 'root') {
return ['ok' => false, 'output' => __('backend.ssh_root_self_lockout')];
}
// Lock-out guard: never disable password auth without a usable key path.
if ($action === 'ssh_password' && ! $enable) {
if ($server->credential?->auth_type === 'password') {
@ -231,8 +238,15 @@ class HardeningService
return match ($action) {
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
// Pin the sshd jail to the SYSTEMD backend (reads the journal) instead of the distro
// default /var/log/auth.log — which is absent on minimal/container images without
// rsyslog, where fail2ban would otherwise fail to start. All supported targets are
// systemd, and `apt/dnf install fail2ban` pulls the systemd binding via Recommends.
'fail2ban' => $enable
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
? $pm->ensureInstalledScript('fail2ban')
.' && mkdir -p /etc/fail2ban/jail.d'
.' && printf \'[sshd]\nbackend = systemd\nenabled = true\n\' > /etc/fail2ban/jail.d/00-clusev-sshd.local'
.' && systemctl enable --now fail2ban'
: 'systemctl disable --now fail2ban',
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),

View File

@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\HealthCheck;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* Runs an uptime probe NATIVELY (Laravel HTTP client / a PHP TCP stream) no SSH, no shell, so the
* target is never interpolated into a command. An HTTP probe reports only up/down + status code +
* latency (never the response body), so it can't exfiltrate an internal endpoint's content. Redirects
* are disabled so a 30x can't bounce the probe to an unseen target.
*/
class HealthService
{
/**
* @return array{ok:bool, latency:?int, detail:string}
*/
public function probe(HealthCheck $check, int $timeout = 5): array
{
return $check->type === 'tcp'
? $this->tcp((string) $check->target, (int) $check->port, $timeout)
: $this->http((string) $check->target, $timeout);
}
private function http(string $url, int $timeout): array
{
$start = microtime(true);
try {
$res = Http::timeout($timeout)->withoutRedirecting()->get($url);
$latency = (int) round((microtime(true) - $start) * 1000);
$status = $res->status();
// 2xx/3xx = up; a 4xx/5xx means the endpoint answered but is unhealthy.
return ['ok' => $status >= 200 && $status < 400, 'latency' => $latency, 'detail' => 'HTTP '.$status];
} catch (Throwable $e) {
return ['ok' => false, 'latency' => null, 'detail' => $this->reason($e->getMessage())];
}
}
private function tcp(string $host, int $port, int $timeout): array
{
$start = microtime(true);
$errno = 0;
$errstr = '';
$sock = @stream_socket_client('tcp://'.$host.':'.$port, $errno, $errstr, $timeout);
if (! $sock) {
return ['ok' => false, 'latency' => null, 'detail' => $errstr !== '' ? $errstr : 'connection failed'];
}
fclose($sock);
$latency = (int) round((microtime(true) - $start) * 1000);
return ['ok' => true, 'latency' => $latency, 'detail' => 'TCP '.$port.' open'];
}
/** Keep the failure reason short + on one line for the compact status row. */
private function reason(string $msg): string
{
$msg = trim(preg_split('/\R/', $msg)[0] ?? $msg);
return mb_strlen($msg) > 80 ? mb_substr($msg, 0, 80).'…' : $msg;
}
}

View File

@ -47,6 +47,36 @@ class MaintenanceService
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
}
/**
* Pending update count AND its security subset in one round-trip. Either value is NULL when the
* query couldn't run (unsupported manager / errored) so the caller shows "unbekannt" rather than
* a misleading 0. Security is best-effort per family; it is always pending.
*
* @return array{pending: ?int, security: ?int}
*/
public function updateCounts(Server $server): array
{
$os = $this->detector->detect($server);
if (! $os->managesPackages()) {
return ['pending' => null, 'security' => null];
}
$res = $this->fleet->runPlain($server, PackageManager::for($os)->countsScript());
if (! str_contains($res['output'], 'CLUSEV_OK')) {
return ['pending' => null, 'security' => null];
}
$pending = preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
$security = preg_match('/CLUSEV_SECURITY=(\d+)/', $res['output'], $s) ? (int) $s[1] : null;
// Security can never exceed total pending (guards a noisy grep).
if ($pending !== null && $security !== null) {
$security = min($security, $pending);
}
return ['pending' => $pending, 'security' => $security];
}
/**
* Refresh the index and apply all upgrades as root, using the host's package
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400

View File

@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\MetricSample;
use App\Models\Server;
/**
* Turns persisted per-minute metric samples into a downsampled cpu/mem/disk (% average) + load
* series for the Server-Details history chart. Bucketing is done in PHP (DB-agnostic, like the
* WireGuard traffic series) each returned point is the AVERAGE of the samples in a bucket, and
* ONLY non-empty buckets are returned (the chart connects consecutive points and the client breaks
* the line only on a real time gap, so normal sampling renders a continuous curve, not dropouts).
*/
class MetricHistory
{
/**
* @return array{window:int, from:int, now:int, bucket:int,
* points:array<int,array{t:int,cpu:int,mem:int,disk:int,load:float}>}
*/
public function series(Server $server, int $windowSeconds): array
{
$windowSeconds = max(300, $windowSeconds);
// Bucket width is at least the 1/min sample interval, so a bucket holds ~one sample and the
// line stays continuous instead of zig-zagging between half-empty buckets.
$width = max(60, (int) floor($windowSeconds / 120));
$buckets = (int) ceil($windowSeconds / $width);
$now = time();
$from = $now - $windowSeconds;
$rows = MetricSample::query()
->where('server_id', $server->id)
->where('sampled_at', '>=', now()->subSeconds($windowSeconds))
->orderBy('sampled_at')
->get(['cpu', 'mem', 'disk', 'load', 'sampled_at']);
$sum = [];
foreach ($rows as $r) {
$idx = (int) floor(($r->sampled_at->getTimestamp() - $from) / $width);
if ($idx < 0) {
continue;
}
if ($idx >= $buckets) {
$idx = $buckets - 1;
}
$sum[$idx] ??= ['cpu' => 0, 'mem' => 0, 'disk' => 0, 'load' => 0.0, 'n' => 0];
$sum[$idx]['cpu'] += (int) $r->cpu;
$sum[$idx]['mem'] += (int) $r->mem;
$sum[$idx]['disk'] += (int) $r->disk;
$sum[$idx]['load'] += (float) $r->load;
$sum[$idx]['n']++;
}
ksort($sum);
$points = [];
foreach ($sum as $i => $b) {
$n = $b['n'];
$points[] = [
't' => (int) round($from + ($i + 0.5) * $width),
'cpu' => (int) round($b['cpu'] / $n),
'mem' => (int) round($b['mem'] / $n),
'disk' => (int) round($b['disk'] / $n),
'load' => round($b['load'] / $n, 2),
];
}
return ['window' => $windowSeconds, 'from' => $from, 'now' => $now, 'bucket' => $width, 'points' => $points];
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Live status of the build pipeline for the currently-cut release candidate, for the dev Release page: tag (Gitea),
* mirror (GitHub-private), CI (GitHub Actions). These are global build facts of one release candidate there is no
* "deployed" step because any server tracking the candidate pulls a release itself (no single staging target).
* Each step is cached briefly; any failure degrades to 'unknown' (never throws). The read-only GitHub
* token and the private staging slug come from config (env) and are never rendered or put in a URL.
*/
class PipelineStatus
{
private const TTL_SECONDS = 15;
/**
* @return array{tag:string, steps:array<int,array{key:string,state:string,detail:?string,url:?string}>, ciRunning:bool}|null
*/
public function forTrackedRc(): ?array
{
$version = (string) config('clusev.version');
if (! str_contains($version, '-rc')) {
return null;
}
$tag = 'v'.ltrim($version, 'vV');
$mirror = $this->mirrorStep($tag);
$ci = $this->ciStep($tag);
$steps = [
['key' => 'tag', 'state' => 'done', 'detail' => $tag, 'url' => null],
$mirror,
$ci,
];
$ciRunning = $mirror['state'] === 'pending'
|| in_array($ci['state'], ['running', 'pending'], true);
return ['tag' => $tag, 'steps' => $steps, 'ciRunning' => $ciRunning];
}
/** Forget the cached step results so the next read re-fetches (driven by the page poll). */
public function refresh(): void
{
$version = (string) config('clusev.version');
if (! str_contains($version, '-rc')) {
return;
}
$tag = 'v'.ltrim($version, 'vV');
foreach (['mirror', 'ci'] as $step) {
Cache::forget("clusev:pipeline:{$tag}:{$step}");
}
}
private function github(): PendingRequest
{
return Http::timeout(5)
->withToken((string) config('clusev.github_token'))
->acceptJson()
->withHeaders(['User-Agent' => 'Clusev-Panel']);
}
/** @return array{key:string,state:string,detail:?string,url:?string} */
private function mirrorStep(string $tag): array
{
return Cache::remember("clusev:pipeline:{$tag}:mirror", self::TTL_SECONDS, function () use ($tag) {
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
try {
$res = $this->github()->get("https://api.github.com/repos/{$slug}/git/refs/tags/{$tag}");
$state = $res->status() === 404 ? 'pending' : ($res->successful() ? 'done' : 'unknown');
return ['key' => 'mirror', 'state' => $state, 'detail' => null, 'url' => null];
} catch (\Throwable $e) {
report($e);
return ['key' => 'mirror', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
});
}
/** @return array{key:string,state:string,detail:?string,url:?string} */
private function ciStep(string $tag): array
{
return Cache::remember("clusev:pipeline:{$tag}:ci", self::TTL_SECONDS, function () use ($tag) {
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
try {
$res = $this->github()->get("https://api.github.com/repos/{$slug}/actions/runs", ['per_page' => 30, 'event' => 'push']);
if (! $res->successful()) {
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
$run = null;
foreach ((array) ($res->json('workflow_runs') ?? []) as $r) {
if (is_array($r) && ($r['head_branch'] ?? null) === $tag) {
$run = $r; // workflow_runs are newest-first
break;
}
}
if ($run === null) {
return ['key' => 'ci', 'state' => 'pending', 'detail' => null, 'url' => null];
}
$status = (string) ($run['status'] ?? '');
$conclusion = (string) ($run['conclusion'] ?? '');
$url = is_string($run['html_url'] ?? null) ? $run['html_url'] : null;
$state = match (true) {
in_array($status, ['queued', 'in_progress', 'waiting', 'requested', 'pending'], true) => 'running',
$status === 'completed' => $conclusion === 'success' ? 'done' : 'failed',
default => 'unknown',
};
return ['key' => 'ci', 'state' => $state, 'detail' => null, 'url' => $url];
} catch (\Throwable $e) {
report($e);
return ['key' => 'ci', 'state' => 'unknown', 'detail' => null, 'url' => null];
}
});
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\Server;
/**
* Turns a server's hardening state into a single security-posture SCORE. Reuses
* HardeningService::state() (the CIS-relevant gates: SSH root/password, fail2ban, firewall) rather
* than re-probing. `neutral` items (auto-updates = an operator preference, not a gate) and
* OS-unsupported items don't count toward the score. The remediation for a failing check is the
* existing hardening toggle on the server-details page.
*/
class PostureService
{
public function __construct(private HardeningService $hardening) {}
/**
* @return array{score:int, rating:string, passed:int, applicable:int,
* checks:array<int, array{key:string,label:string,detail:string,secure:bool,supported:bool,neutral:bool,counts:bool}>}
*/
public function score(Server $server): array
{
$items = $this->hardening->state($server); // throws if the host can't be read
$checks = [];
$passed = 0;
$applicable = 0;
foreach ($items as $it) {
// A check counts toward the score only when the OS supports it AND it's a real security
// gate (not the neutral auto-updates preference).
$counts = $it['supported'] && ! $it['neutral'];
if ($counts) {
$applicable++;
if ($it['secure']) {
$passed++;
}
}
$checks[] = [
'key' => $it['key'],
'label' => $it['label'],
'detail' => $it['detail'],
'secure' => (bool) $it['secure'],
'supported' => (bool) $it['supported'],
'neutral' => (bool) $it['neutral'],
'counts' => $counts,
];
}
$score = $applicable > 0 ? (int) round($passed / $applicable * 100) : 100;
return [
'score' => $score,
'rating' => $this->rating($score),
'passed' => $passed,
'applicable' => $applicable,
'checks' => $checks,
];
}
/** strong ≥90 · fair ≥60 · weak below — mapped to the status triad in the UI. */
private function rating(int $score): string
{
return $score >= 90 ? 'strong' : ($score >= 60 ? 'fair' : 'weak');
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Triggers the public-promotion / yank GitHub Actions workflows on the private staging repo via the
* GitHub API (workflow_dispatch), and reads the public repo's tags. The trigger token
* (config clusev.github_token, needs Actions:Write) ONLY dispatches the workflows do the public
* push/delete with their own PUBLIC_REPO_TOKEN. Reads of the public repo's tags are anonymous (it is
* public). Every call degrades to false / [] (never throws); the token is only sent in the header.
*/
class PromotionService
{
public function deployPublic(string $rcTag): bool
{
return $this->dispatch('promote-public.yml', ['tag' => $rcTag]);
}
public function yank(string $tag): bool
{
return $this->dispatch('yank.yml', ['tag' => $tag]);
}
/**
* The public repo's tag names, newest first (anonymous read; the repo is public). [] on failure.
*
* @return array<int,string>
*/
public function publicTags(int $limit = 10): array
{
$slug = (string) config('clusev.public_slug');
if ($slug === '') {
return [];
}
// Cached: render() (incl. the 5s page poll) calls this every render, and the anonymous GitHub
// tag API is rate-limited (60/h per IP). 120s keeps it well under the limit; a yank reflects
// once the workflow has actually deleted the tag (~minutes) anyway, so staleness is harmless.
return Cache::remember("clusev:public-tags:{$slug}:{$limit}", 120, function () use ($slug, $limit) {
try {
$res = Http::timeout(5)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
->get("https://api.github.com/repos/{$slug}/tags", ['per_page' => $limit]);
if (! $res->successful()) {
return [];
}
return array_values(array_filter(
array_map(static fn ($t): string => is_array($t) ? (string) ($t['name'] ?? '') : '', (array) $res->json()),
static fn (string $n): bool => $n !== '',
));
} catch (\Throwable $e) {
report($e);
return [];
}
});
}
/** @param array<string,string> $inputs */
private function dispatch(string $workflowFile, array $inputs): bool
{
$token = (string) config('clusev.github_token');
$slug = (string) config('clusev.staging_slug');
if ($token === '' || $slug === '') {
return false;
}
try {
$res = Http::timeout(5)->withToken($token)->acceptJson()->withHeaders(['User-Agent' => 'Clusev-Panel'])
->post("https://api.github.com/repos/{$slug}/actions/workflows/{$workflowFile}/dispatches", [
'ref' => (string) config('clusev.release_branch'),
'inputs' => $inputs,
]);
return $res->successful(); // 204 No Content on success
} catch (\Throwable $e) {
report($e);
return false;
}
}
}

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