Commit Graph

127 Commits (4248c83c42ada70f05f583d9138c4526bc6ae177)

Author SHA1 Message Date
boban c696950f25 feat(release): localized strings + audit labels for staging releases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:56:09 +02:00
boban 4b2aee3941 fix(versions): show what's-new (v-prefixed ref) + sidebar update badge (v0.9.51)
Two fixes the operator asked for:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:25:19 +02:00
boban 94425f93e8 fix(wg): remove SSH hint from setup form + time out a non-responding host (no endless spinner)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:38:43 +02:00
boban 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 7ccefa5890 feat(wg): gate toggle + endpoint/port/subnet settings from the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:53:29 +02:00
boban ee8f2bac91 feat(wg): add/remove peers from the dashboard (show-once config + QR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:36:26 +02:00
boban 0c9aa12aab feat(wg): traffic-history SVG chart + window selector on /wireguard 2026-06-21 00:07:18 +02:00
boban 96349808e2 chore(wg): drop unused wireguard lang keys (server_key, transfer)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:41:18 +02:00
boban e640a96ca0 feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:35:58 +02:00
boban c9556bffbf feat(wg): WireGuard help topic (registration + DE/EN content + test) 2026-06-20 22:46:04 +02:00
boban 552417fd4b feat(versions): grouped + paginated changelog browser (no endless scroll)
Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:40:35 +02:00
boban d2f70900ff fix(update): graceful Livewire-less /update-progress page — no more 502/whitescreen on update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:14:11 +02:00
boban 7b4215d055 feat(auth): Anmeldeschutz settings tab + unban confirm flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:13:12 +02:00
boban 2936af82bd feat(auth): guests-only BlockBannedIp middleware + 403 page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:45:16 +02:00
boban 7645241a7a feat(auth): ValidIpOrCidr validation rule 2026-06-20 17:42:06 +02:00
boban 7c1a32518d i18n(auth): drop orphaned 2FA recovery hint strings 2026-06-20 15:47:26 +02:00
boban 4e32f76d1b polish(auth): distinct backup page title, autocomplete off, empty-code test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:38:38 +02:00
boban f581529164 i18n(auth): add 2FA backup-view strings 2026-06-20 15:21:52 +02:00
boban 8d2b7087a9 feat: short clusev commands in versions panel, lang strings, MOTD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:18:25 +02:00
boban 437a22592b feat: help tab-URL + Commands/CLI topic + short command copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:06:45 +02:00
boban 28c28df868 feat(help): in-panel help page scaffold + overview topic
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:07:25 +02:00
boban d44f6930bb feat(auth): SMTP-aware forgot-password — 15-min e-mail link + 2FA fallback; recovery note out of the public screen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:53:37 +02:00
boban 57a3dd51b6 feat(security): signed confirm-action tokens + airtight backup-code reveal
Two hardening features completed in parallel (each with its own spec + tests),
sharing call sites (TwoFactorSetup, WebauthnKeys, routes) so committed together:

- ConfirmAction token hardening: destructive confirm flows now carry a server-issued,
  single-use, signed token (App\Support\Confirm\ConfirmToken) instead of trusting a
  client-mutable event/params/auditTarget. Every #[On] apply handler consumes + validates
  the token (forged/replayed/direct-bypass calls no-op). Server-scoped per action; closes
  the codebase-wide confirm-bypass + audit-forgery vector across Security, Sessions, Users,
  WebauthnKeys, Servers\Show, Services, System, Files.
- Backup-code airtight reveal: codes are revealed via a transient channel, not a persisted
  Livewire property, so a captured/replayed snapshot can't re-render them; the recovery
  download is grant-gated.

Full suite green (162).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:35:33 +02:00
boban dd891cd11e Reveal backup codes only when freshly generated
Backup (recovery) codes must be viewable ONLY at the moment they are
generated — at first-factor enrollment or on regenerate — and NEVER
re-viewable afterward from "Backup-Codes verwalten".

- Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a
  one-time session flag (2fa.codes_fresh); regenerate() sets it. render()
  emits codes ONLY when revealed, so a client cannot tamper a flag to
  re-view stored codes.
- View: revealed state shows codes grid + download + warning + close;
  hidden state shows a security notice + Neu erzeugen + close (no codes,
  no download).
- TwoFactorSetup::confirm() and WebauthnKeys::register() put
  2fa.codes_fresh on first-factor code generation (put, survives the
  redirect + later openModal request).
- Add recovery_hidden_notice (DE/EN parity).
- Tests: manage view hides codes; fresh flag reveals once; regenerate
  reveals the new set; first-factor paths set the flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:14:59 +02:00
boban bdccd05cd9 fix(system): shorten TLS-mode label (no wrap) + clean domain placeholder
'Eingebautes TLS' (detail moves to the description line); placeholder is just the
example host (the 'leer = IP' hint is already in the help text).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:11:48 +02:00
boban 7e1d342844 redesign(system): compose Domain/TLS/Release-Kanal into a 2-column layout
The System page was just constrained to max-w-3xl — narrower, not designed.
Rework it into an intentional composition at max-w-5xl:

- Critical alerts (restart-required + bare-IP) are now full-width banners
  above the working area, not buried in stacked inset blocks.
- A 2-column lg grid separates "do" from "know": left/main panel holds the
  actionable form (domain input + Speichern, TLS-mode selector); right/side
  panel is the status & recovery stack (access address, TLS state, SSH reset).
- Release-Kanal stays a full-width band below. Everything stacks to one
  column on mobile.
- Header HTTPS/Plaintext pill made more prominent; domain Save promoted to
  the primary action.

All wire bindings (confirmDomain, confirmTlsMode, restartNow, restartPending/
restartRequested) and every __() key preserved; two new section-label keys
(section_config, section_status) added with DE/EN parity. Tactical-Terminal
tokens only, no inline styles, existing component kit reused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:06:49 +02:00
boban d384a7d918 fix(2fa): Security-Keys subtitle reflects optional/pluggable model (key alone, with TOTP, or both)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:02:28 +02:00
boban 3399a5017a feat(system): auto-restart sentinel — one-click restart via host watcher (no docker socket)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:50 +02:00
boban 73eecd03e0 feat(mail): SMTP configuration in Settings (encrypted password, runtime override, test-send)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:19 +02:00
boban bb1ad53030 feat(audit): configurable retention + scheduled clusev:prune-audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:40:17 +02:00
boban 4ced37ac40 feat(accounts): multi-user admin management — create (temp pw), list, remove, force-logout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:39:34 +02:00
boban 0cdc6c3a7b feat(sessions): database sessions + list/revoke (other devices, per-user, global) with remember-token rotation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:34:00 +02:00
boban 5f6ad27329 refactor(settings): split into per-tab nested components (Profile, Security) + tab hosts for Users/Sessions/Email
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:23:25 +02:00
boban 883b6f66fe fix(system): constrain stretched layout, real save icon, de-Caddy copy, SSH-reset hint
System page capped to a readable width; Speichern gets a proper save glyph (added
save/eye/eye-off/user-plus to the icon set); operator-facing 'Caddy' softened to
neutral wording; added a 'fully locked out -> SSH + clusev:reset-admin' hint. Settings
email field to type=text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:10:37 +02:00
boban 77fee98452 feat(auth): show/hide password toggle; email inputs to type=text (class validates)
Login + password-change + reset + forgot get an eye toggle. Email fields switch to
type=text so validation lives only in the Livewire class (consistent custom messages).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:10:37 +02:00
boban 8cf8c771c8 feat(files): image preview in the file manager; modal polish
Opening an image now renders it (SFTP base64 data-URI, 5 MB cap, save disabled for
images) instead of the text editor. Recovery-codes modal buttons realigned + the
close button relabeled 'Schließen' (it only closes). All modal textareas resize-none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:10:37 +02:00
boban 8b1bbf401a feat(servers): move fail2ban banned-IP list into a scrollable modal
The fail2ban-Status panel listed every jail + banned IP inline (very long). Now a
compact summary + 'Gesperrte IPs ansehen' opens a scrollable modal (Fail2banBans)
that reuses Fail2banService::status/unban and dispatches fail2banChanged. serverId
is #[Locked].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:10:36 +02:00
boban 8ef907d429 feat(tls): System TLS-mode toggle + Caddy trusted_proxies (TRUSTED_PROXY_CIDR) + firewall hint
- Add TLS_MODES const, tlsMode property, confirmTlsMode/applyTlsMode (mirroring channel toggle) to System\Index
- Segmented TLS-mode selector + external-proxy warning hint in system/index.blade.php
- DE + EN lang keys for all new strings (tls_mode_*, change_tls_*, tls_saved_notify)
- Caddyfile: servers { trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} } in global block
- docker-compose.prod.yml caddy service: TRUSTED_PROXY_CIDR env var (safe default 127.0.0.1/32)
- .env.example: documented TRUSTED_PROXY_CIDR knob
- tests/Feature/TlsModeToggleTest.php: 2 new tests (all 7 TLS tests green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:38:21 +02:00
boban 8d7f2da8d0 feat(ssh): provision modal + hardening-row action — confirm, run, reveal key once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:09:59 +02:00
boban b847b80ab6 feat(ssh): SshKeyProvisioner — generate+install key, verify, switch credential, then disable password (rollback-safe)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:57:45 +02:00
boban 61df4497d7 feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path
Guard the TOTP verifyKey call behind hasTotp() so a key-only user's null
two_factor_secret never reaches Google2FA::verifyKey(); they reset via a
backup code. Also adds the reset_no_2fa_note lang key (EN + DE) rendered
on the forgot-password form explaining the no-2FA recovery options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:26:59 +02:00
boban 00337e038c feat(2fa): Settings manages TOTP + keys independently; key can be only factor; empfohlen hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:21:28 +02:00
boban aa43947dcf fix(2fa): memoize challenge pending-user lookup; assert key-only view; tidy hint wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:17:18 +02:00
boban 55d513f241 feat(2fa): challenge adapts to factor — TOTP field gated, key-only leads with the key + backup code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:10:25 +02:00
boban e9a6a6c083 feat(2fa): recovery codes become a modal; drop the dedicated page + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:50:11 +02:00
boban 44d4046526 fix(services): live journal poll + command-palette server search
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last
N lines once and never refreshed, so it was neither live nor complete.

- FleetService: `journalctl --show-cursor` + new journalSince() reads only entries
  after the stored journald cursor (no gaps, no dup appends), capped to a bounded
  live tail; parseJournal split into splitJournalCursor + parseJournalLines.
- Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor
  and caps retained rows at 300 (fetch ceiling == display ceiling).
- Honest labels: drop the misleading "-f"; badge stays "live" (now true).

Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine
cmdk x-data; servers surface only while searching, matched on name OR IP, and
selecting one navigates to /servers/<uuid>.

Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell —
no injection (Codex-reviewed: no P1, no security findings). Removes stock
ExampleTest (asserted 200 on an auth-gated route → always 302).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:13:08 +02:00
boban 7afa50d253 feat(webauthn): use a security key at the login challenge
When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:26:58 +02:00
boban b2cb94bb32 feat(auth): accept a 2FA backup code at the login challenge
The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:45:02 +02:00
boban c5d3de6e0b feat(branding): favicons + PWA manifest, and custom branded error pages
Branding assets (public/, served directly):
- favicon.svg (scalable, primary), favicon.ico (16/32/48 PNG-embedded),
  apple-touch-icon.png (180), icon-192/512.png, site.webmanifest. The mark is
  the in-app brand glyph (orange Lucide "server" on dark). Wired into both
  layouts via a shared partials/head-icons (rel=icon svg+ico, apple-touch,
  manifest, theme-color #06080A). nginx serves the manifest as
  application/manifest+json.

Never show raw error text (custom messages only):
- Custom branded resources/views/errors/{403,404,419,429,500,503}.blade.php on a
  self-contained error layout (design tokens, brand mark, bilingual via
  lang/errors.php) — replaces Laravel's default Symfony/whoops pages.
- docker-compose.prod.yml pins APP_DEBUG=false so a prod error can never render a
  stack trace (the custom pages show instead).
- Files download error dispatched a bare $e->getMessage(); wrap it in the
  localized files.download_failed (consistent with upload/delete).

Verified in-browser: icon links present + all assets 200; custom 404 renders the
branded German message (no raw "Not Found"/trace), responsive at 375, 0 console.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:27:49 +02:00
boban 37efa64b9d fix(ssh): require an exec marker in the connection probe
Auth success alone does not prove the server can run commands (a server that
authenticates but rejects exec channels would be accepted, yet every fleet
operation runs commands). The probe now echoes a unique marker and requires it
in the output — proving exec works, while still tolerating servers that omit
the optional exit-status packet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:46:59 +02:00
boban a5c518822b feat(servers): hint that disabling password login leaves key-only access
Adds a muted one-line hint on the SSH-Passwort-Login checklist row while
password auth is still on, so the operator knows access becomes SSH-key only
(and to deposit a key first) before toggling — mirroring the lockout guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:30:07 +02:00
boban a9334d11de feat(servers): verify SSH on create + start in "Initialisierung"
CreateServer now probes the entered SSH login inside the create transaction; a
failed probe throws and rolls back (no half-registered server), surfacing the
SSH reason on the form. Successful creation starts the server as `pending`
("Initialisierung") instead of red/offline until first contact promotes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:26:15 +02:00
boban 615f440515 feat(ui): add "Initialisierung" (pending) server status
New `pending` status renders cyan (dot + pill, soft ping) instead of red, for
freshly created servers that have not been contacted yet. Wired into the
status-pill/dot components, the fleet list, and server-item, with a bilingual
`servers.status_pending` label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:20:32 +02:00
boban a44e29426a feat(domain): change the panel domain from the dashboard (v0.4.0)
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.

How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
  setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
  DIRECTLY (cache-independent) and retries until the settings table is readable; if it
  never is, it freezes the env fallback so the active domain is always a FIXED value
  (never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
  publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
  domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
  hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
  (/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
  no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
  command; DE/EN strings (R16).

Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
  always a recovery path. trustProxies('*') only in production (dev can't be tricked into
  faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
  the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
  and IP-recovery redirects stay on the IP.

Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).

Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:38:16 +02:00
boban ec1516184e i18n: complete DE/EN localization (modals, shell, backend) + v0.3.0
- modals: wire 5 hard-coded views + 10 components to lang/{de,en}/modals.php
- shell: command-palette (incl. app.js cmdk(nav,actions) refactor), server-item,
  toaster fallback
- backend: new lang/{de,en}/backend.php; localize Hardening/Firewall/Fail2ban/
  Maintenance services, OsProfile, plus FleetService + CredentialVault errors
- fix: localized page <title> on Dashboard/Servers index+show (->title(), the
  Livewire-3 way; the stray title() methods were never auto-called)
- fix: topbar inline @php() was swallowed by Blade's raw-block precompiler up to a
  later @endphp -> 500 on every authed page; converted to block @php ... @endphp
- versions: localize changelog section labels via a canonical heading map
- translation-quality pass: Unlock, Add credentials, clearer auth/notify copy

Bump 0.2.1 -> 0.3.0; CHANGELOG.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:36:16 +02:00