Commit Graph

162 Commits (79862ab0147b0d553940456536a45e4349110a29)

Author SHA1 Message Date
boban 79862ab014 feat(patch): fleet patch view + security-update awareness (feature 6/8)
Fleet-wide package-update visibility: pending + SECURITY update counts per server, one-click patch.
Extends MaintenanceService (single-server pending/apply already existed) with the security subset
and a fleet aggregation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
boban 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 c63af35194 feat(honeypot): surface login attempts in the dashboard (threats KPI, tried-credentials, sidebar badge) 2026-07-05 10:49:38 +02:00
boban 72de7b9a22 fix(rbac,honeypot): gate audit-retention + email sendTest; honeytoken scans JSON body
Re-review (completeness sweep + Codex) found: Audit\Index::saveRetention wrote the global
audit_retention_days policy ungated (a non-admin could shrink retention and prune audit evidence)
-> manage-panel guard + @can-hidden control; Settings\Email::sendTest lacked the role guard that
mount/save already had -> manage-panel; DetectHoneytoken read only form-encoded bodies via post(),
missing a JSON {"api_key":"<canary>"} replay -> now scans json() too (still body-only, no query).
2026-07-05 02:28:13 +02:00
boban e602320c9d fix(rbac,honeypot): gate settings/release surfaces + per-install canaries, no reflected/auth-user honeypot bans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 02:16:37 +02:00
boban e58d1a4b07 feat(rbac): hide the system-update trigger behind manage-fleet too 2026-07-05 01:51:12 +02:00
boban 8b34ed8ef5 feat(rbac): hide gated controls in the UI with @can (operator/viewer see no dead buttons) 2026-07-05 01:49:38 +02:00
boban 4f9699d470 feat(honeypot): threats dashboard — probe feed, KPIs, banned-IP management
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:38:22 +02:00
boban 61b8419106 feat(rbac): user-management roles UI — role badge, selector, role-change with last-admin guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:15:32 +02:00
boban 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 6348d269a5 docs: fix stale first-login copy (random initial password, rotation optional)
The in-panel help "First login" section still claimed you log in with the
literal default password `clusev` and that "the panel forces a password change
immediately". Both are false since this cycle: the installer generates a RANDOM
password shown once in the closing summary, and EnsureSecurityOnboarded only
nudges (skippable), never forces.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:13:26 +02:00
boban 787e48d8fd feat(release): live pipeline rail + test-server input on the Release page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:55:14 +02:00
boban 6a284caa44 design(release): rebuild the Release page in the Tactical Terminal system
Replace the bland version + four plain buttons with a composed layout: a current-
version hero (mono version, channel + source), tactile bump tiles showing the
current → target transition with a Beta-tag badge (continue-beta highlighted
accent), styled running/last-pushed states, and a vertical pipeline rail
(Gitea → GitHub-privat → CI → Staging). Tokens-only, responsive 375/768/1280,
German, no emoji; the R5 confirm flow + component contract are unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:25:41 +02:00