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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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).
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).
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>