Commit Graph

96 Commits (4248c83c42ada70f05f583d9138c4526bc6ae177)

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 9c0d390365 refactor(wireguard): remove dead code + DRY the WG surface (no behavior change)
Verified dead-code + duplication cleanup over the WireGuard feature surface
(found via an audit workflow, every item adversarially re-verified by grep):

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:25:19 +02:00
boban 8e05cf62ec feat(wg): set WireGuard up from the dashboard (setup form + bridge action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 08:09:35 +02:00
boban ed5506e558 feat(wg): WgBridge — app side of the write-bridge (validate + request + result) 2026-06-21 00:32:02 +02:00
boban 49f70c0c9f fix(wg): WgTraffic up = throughput delta (not raw tx) — consistent with down
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:04:07 +02:00
boban 130e1aeaf6 feat(wg): WgTraffic — bucketed down/up throughput series 2026-06-21 00:02:35 +02:00
boban 9b835e9dbe feat(wg): WgStatus reader + auth-gated /wg-status.json route 2026-06-20 23:32:44 +02:00
boban 6c81ea7fd1 harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test 2026-06-20 18:53:52 +02:00
boban 49d5c0aed3 harden(auth): BruteforceGuard review fixes (hit count, mask bounds, once-guard, mapped loopback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:39:01 +02:00