Commit Graph

193 Commits (142b531d45708aa301eb8a1e3f9372f44988ccfe)

Author SHA1 Message Date
boban 084c21a869 feat(update): real phase progress feed + deep-linkable changelog series/page
Update-progress phases were a pure time guess, so a fast update sat on "fetch"
then snapped all four green at once. Now the host updater publishes its real
macro-stage and the page tracks it:

- update.sh / install.sh write the current stage (fetch|build|restart|migrate|
  done) to run/update-phase.json — best-effort (|| true, never aborts an update).
- new public GET /update-status.json serves that stage (whitelisted) to the page.
- update-progress.blade.php drives the checklist from the feed in REAL order
  (fetch → build → restart → migrate), falling back to the time heuristic when no
  feed is present, and completes on the feed's 'done' (or the version flip, with a
  25s grace fail-safe). Like the 502 fix, the live experience lands one update
  after this ships (the page shown DURING an update is the old version's).

Also: Versions changelog series + page are now #[Url]-synced (?series=&page=),
so a series/page is shareable and survives reload / back-button.

Tests: /update-status.json (null / whitelisted / rejected stage), the page polls
the feed, and the changelog deep-link reads ?series=&page=.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:08:41 +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 05150bf89f fix(update): wait for actual new version before redirect (no more 502)
The update-progress page declared completion on two consecutive 200s from
the /up health route. But the OLD container keeps answering /up 200
throughout the minutes-long rebuild, so the page redirected prematurely
back into the teardown → 502 / white screen.

Now the page polls a lightweight public /version.json probe and finishes
only when the running version has actually moved past the pre-update one
(passed as ?from=), or after an observed down→up cycle for a same-version
redeploy. The 10-minute timeout + manual-reload fallback are unchanged.

- routes/web.php: add public GET /version.json; sanitise+pass ?from= to the view
- Versions/Index.php: include the installed version as ?from= in the redirect
- update-progress.blade.php: version-gated completion (fromVersion/sawDown)
- UpdateProgressTest: probe endpoint, embedded baseline, from-sanitisation, no /up poll

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:02:21 +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 6c81ea7fd1 harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test 2026-06-20 18:53:52 +02:00
boban cabad0e770 harden(auth): scope whitelist purge to active bans, cover unbanAll, hoist ip 2026-06-20 18:20:19 +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 96f86e2761 feat(auth): clusev:unban CLI + host wrapper escape hatch 2026-06-20 18:07:43 +02:00
boban 01867dba09 harden(auth): capture login IP once, assert 2fa-failed audit 2026-06-20 18:06:11 +02:00
boban 968d8c48c9 feat(auth): feed BruteforceGuard from login + 2FA failures, audit them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:54 +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 49d5c0aed3 harden(auth): BruteforceGuard review fixes (hit count, mask bounds, once-guard, mapped loopback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:39:01 +02:00
boban ce98fbdba0 feat(auth): BruteforceGuard with inet_pton CIDR exemptions 2026-06-20 17:29:00 +02:00
boban 1f7b06c576 feat(auth): BannedIp model + migration 2026-06-20 17:26:28 +02:00
boban 4ea4050c80 fix(auth): R15 review — restore one-time-code autofill, memoise pendingUser in verify
- backup field: autocomplete=off -> one-time-code (backup codes ARE one-time codes;
  matches the main challenge field, the rest of the app, and the spec; restores
  password-manager OTP autofill that the earlier polish broke)
- both verify() use the trait's memoised $this->pendingUser() instead of a fresh
  User::find(), saving a redundant query on the failed-attempt re-render (Pint dropped
  the now-unused User import in TwoFactorBackup)
- spec: align trait backing-state visibility note to the implemented 'private'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:19:40 +02:00
boban 8a8232a355 test(auth): cover TOTP+key challenge branch; clarify mount comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:46:37 +02:00
boban 67ce2de538 feat(auth): split backup code out of the main 2FA challenge view
Mount now redirects key-only users without a secure context straight to
the dedicated backup view. The main challenge form renders only for TOTP
users; a subordinate button links to two-factor.challenge.backup instead
of embedding the field inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:41:19 +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 e8bbe21b78 feat(auth): dedicated backup-code 2FA view + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:31:32 +02:00
boban 0765293123 refactor(auth): tighten trait visibility, return type, @mixin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:28:36 +02:00
boban 1fef9e61fe refactor(auth): extract CompletesTwoFactorChallenge trait 2026-06-20 15:24:09 +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 a709ef772c fix(assets): drop Vite preload hints (silence 'preloaded but not used')
With one CSS + one JS entry the <link rel=preload> sits next to the matching
stylesheet/script and adds no value, but it triggers Chrome's 'preloaded but
not used' console warning (amplified by DevTools 'Disable cache', which
double-fetches the CSS). Disable preload tag generation; stylesheet and script
still load normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:50:54 +02:00
boban c020af4c09 fix(webauthn): add security-key hint so passkey managers defer
Even with authenticatorAttachment=cross-platform, Bitwarden's extension
intercepted navigator.credentials.create and offered to save a passkey. Add the
WebAuthn L3 'hints: [security-key]' to both registration and assertion options;
browsers and passkey managers that honor hints step aside and let the hardware
key prompt through. Injected into the client payload only (the session copy used
for server validation is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:30:52 +02:00
boban 5ebee9b4a5 fix(webauthn): target a hardware security key (YubiKey), one-touch second factor
Registration set no authenticatorSelection, so the browser offered the
passkey-manager 'save passkey' flow (Bitwarden) instead of prompting for the
YubiKey. Now request a cross-platform, non-resident, presence-only credential:
authenticatorAttachment=cross-platform (roaming key, not a platform/manager
passkey), residentKey=discouraged (non-discoverable second factor — what keeps
passkey managers out), userVerification=discouraged (single touch, no PIN).
Login assertion also uses discouraged so it is: insert key, one tap, done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:23:01 +02:00
boban 5abf984df2 fix(webauthn): enable security keys behind an external TLS proxy
WebauthnService::available() keyed on request()->isSecure(), which is false in
external-proxy mode (the forwarded https scheme isn't trusted without
TRUSTED_PROXY_CIDR) — so hardware keys were locked even though the panel runs
over HTTPS on the domain. Gate on being reached via the active domain instead
(always HTTPS through the front door); the bare-IP recovery host stays locked.
A core security feature no longer depends on an optional proxy setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:11:20 +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 4bcdafbeff fix(tls): force https URL generation for the active domain (external-proxy mode)
Behind an external TLS proxy, Clusev's own Caddy only honors the upstream's
X-Forwarded-Proto when TRUSTED_PROXY_CIDR is set. Without it the app saw http,
asset() emitted http:// URLs, and the HTTPS page's CSP ('self') blocked them —
the panel loaded with no CSS/JS over the domain. PanelScheme now forces the URL
root + scheme to https://<domain> whenever the active domain is being served,
regardless of the (possibly untrusted) request scheme. The bare-IP recovery
path returns earlier and stays on HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:35:06 +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 07020a9ea0 feat(versions): auto-check for updates on page load
The available-update banner + button now appear automatically when the page
opens, instead of only after clicking 'check'. autoCheck() runs via wire:init
(async, non-render-blocking) and no-ops while an update is in progress so it
never clobbers the running/poll state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:22:53 +02:00
boban 6b5bc6938c fix(versions): persist in-progress update state in Redis (survives reload)
0.9.12 derived the running state from the sentinel file, which the host
watcher consumes BEFORE update.sh runs — so a reload mid-update showed nothing.
Store an 'update in progress' marker (with the base version) in the cache
(Redis in prod) instead: it survives both the reload and the app rebuild. On
load, mount() resumes the 'running' notice + polling, or — if the running
version has already moved past the base — announces completion and clears the
marker. 15-minute TTL backstop so a failed update can't wedge the banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:18:34 +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 deea1a802c fix(versions): never show a 'latest release' older than the installed version
A stale 30-minute update-check cache could render a latest tag below the
installed version right after an update (e.g. 'latest v0.9.9' next to
'installed v0.9.10'), which reads backwards. The passive display now discards a
cached value lower than the installed version until a fresh check resolves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:43:46 +02:00
boban 81a9e4d641 fix(versions): show build commit + branch in production
The Version page read the deployed commit/branch from .git at runtime, which
the prod Docker image does not contain — so it showed 'Build —' and the channel
as the branch. install.sh now bakes the host's git short-SHA and branch into
.env (CLUSEV_BUILD_SHA / CLUSEV_BUILD_BRANCH); the page prefers those and keeps
the live .git read as the dev fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:35:35 +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 1c3d3d6a01 fix(versions): resolve latest release via the Git host tag API (works in prod)
The update check read the newest release tag from local .git, which the
production Docker image does not contain — so 'check for updates' always
reported 'no release tagged' in prod even when a newer version existed. It now
queries the public, read-only Gitea tags API (anonymous; no token leaves the
panel), keeps local .git only as a dev fallback, and caches the result so a
page render never makes a network call (only the explicit action does).
Channel-aware (stable = vX.Y.Z only; beta also sees prereleases) and degrades
silently when the host is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:31:10 +02:00
boban b1fb0e766a feat(install): fixed default admin e-mail admin@clusev.local
Default the first-admin login e-mail to a predictable admin@clusev.local
when no --email / CLUSEV_ADMIN_EMAIL is given, instead of a host-derived
admin@<host> address the operator would have to guess. Combined with the
default password 'clusev' (forced change on first login), a fresh install
now has a fully predictable initial login. Override at install time or
change it later under Settings -> Profile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:54:03 +02:00
boban 9215a5b908 chore(release): v0.9.5
- Initial admin now uses the standard default password "clusev" (operator decision) with
  must_change_password forcing a new password on first login; banner/MOTD/README tell the
  operator to change it immediately. Idempotent no-op when an admin already exists.
- Richer host MOTD (CLUSEV wordmark, version, live stack status, login + management hints).
- README: install block now installs git (minimal Debian/Ubuntu lack it) and lists it as a
  prerequisite — found by a full fresh-VM install test (install + idempotent re-run verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:43:04 +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 2520b87982 fix(security): flatten reset-timing residuals from Codex review
Follow-up hardening on top of 2171895 (which v0.9.2 shipped). That first pass
introduced a cost-12 bcrypt dummy that was *slower* than the real verify path —
a reverse timing oracle plus CPU-DoS amplification. Replace it with work that
mirrors the real path instead of exceeding it, and close the remaining query /
exception asymmetries:

- resetPassword(): resolve 2FA with a uniform lookup — always exactly one
  webauthn-existence query (even for a TOTP user, and for a missing account) so
  its presence/absence can't tell accounts apart. Every path now does one users
  lookup + one webauthn query + one Google2FA HMAC + one locked recovery-code
  transaction (real data when present, throwaway data otherwise).
- Split burnVerificationTime() into dummyTotpVerify() (HMAC) and
  dummyRecoveryLookup() (locked SELECT in a transaction); drop the bcrypt
  Hash::check entirely. A webauthn-only account spends an equivalent throwaway
  HMAC so its crypto cost matches a TOTP account.
- dummyRecoveryLookup() no longer catches DB errors: a DB failure must surface
  identically to the real useRecoveryCode(), else an outage is an error-response
  oracle (generic for unknown, 500 for a real account).
- sendResetLink(): report() the swallowed Throwable so a broken queue/token
  store is detectable instead of silently "succeeding" for every address.

Tests: ForgotPasswordTimingTest now asserts an identical (users,
webauthn_credentials) query fingerprint across TOTP, no-2FA, webauthn-only, and
unknown-email branches, plus queued-mail and rate-limiter parity. Codex re-review
verdict: clean (residual sub-microsecond crypto differences are below the
network/DB noise floor for this self-hosted single-admin threat model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:43:40 +02:00
boban 217189598b fix(security): constant-time password-reset (flatten account-enumeration timing)
Queues the reset-link notification so sendResetLink returns in constant time regardless
of account existence; resetPassword does equivalent dummy verify work for unknown/no-2FA
users. Closes the timing side-channels flagged on the forgot-password review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:16:30 +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 6290cec6ab fix(2fa): bound the backup-code reveal flag with a 10-minute TTL
The 2fa.codes_fresh reveal flag is now a timestamp (set at enrollment) and
RecoveryCodes::mount() only reveals within [now-600s, now], symmetric to the
download-grant window. A reveal flag whose modal never opened can no longer
re-reveal the codes later in the session. FirstFactorCodesTest asserts the
flag is an int.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:42:58 +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 9a6c09e488 fix(2fa): close two re-view holes in backup-codes show-once
Stored recovery codes were re-viewable two ways after their one-time
reveal. Both paths are now closed so codes are only viewable/downloadable
right after a fresh generation (enrollment or regenerate).

HOLE 1 — RecoveryCodes::$revealed was a mutable public Livewire prop, so
a crafted /livewire/update could flip it to true and make render() emit
stored codes. Annotated with #[Locked]; Livewire now rejects any
client-side write (set still happens server-side in mount()/regenerate()).

HOLE 2 — two-factor.recovery.download was auth-gated only, so any authed
user could GET it anytime to re-download stored codes. Added a one-time
session grant (2fa.download_grant) put alongside the existing
2fa.codes_fresh wherever codes are freshly generated (TwoFactorSetup,
WebauthnKeys, RecoveryCodes::regenerate); the route now
abort_unless(pull(grant), 403) before streaming — one download per fresh
generation, later direct hits 403.

Tests: lock rejection + download-grant lifecycle (forbidden → granted ok
→ consumed forbidden). Full suite 137 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:21:45 +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 4eeff4916a feat(fail2ban): lazy-load Gesperrte IPs modal behind a skeleton
The "Gesperrte IPs ansehen" modal did its SSH Fail2banService::status read
in mount(), so clicking the trigger blocked for seconds with no spinner
before the modal appeared.

Split mount() into an instant mount() (stores serverId only) plus a
load() method fired by wire:init, mirroring Services\Index. While
! $loaded the view renders a 4-row skeleton inside the max-h-96 scroll
container; once loaded it renders the real jail/IP list (or empty/error).
The unban flow still re-reads via loadJails() and dispatches
fail2banChanged, keeping #[Locked] $serverId and the audit intact.

Adds Fail2banBansModalTest covering the instant mount (status never
called), load() rendering the list, read-error surfacing, and both
unban success (audit + refresh + dispatch) and failure paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 03:05:35 +02:00
boban 79c25fbf83 fix(accounts): make last-user removal atomic (lock + recount) to prevent a zero-user lock-out race
Codex P2: concurrent deletes could both pass the >1 check. Now the count + delete run in
one transaction under a row lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:49:21 +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 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 991e17fcee fix(tls): audit TLS-mode changes in applyTlsMode (covers direct Livewire calls, no double-audit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:49:31 +02:00
boban bd6ba03c3c fix(security): register EnsureSecurityOnboarded as persistent Livewire middleware
Re-applies the onboarding gate to /livewire/update component calls, not just the
initial page GET, so a component mounted while onboarded cannot run destructive
actions after the account is reverted to must_change_password mid-session.
Addresses the codebase-wide Codex finding flagged during the SSH-key-provision review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:40:31 +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 99a183bcc7 feat(tls): external reverse-proxy mode core — no ACME + no forced HTTPS redirect (host check kept)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:31:04 +02:00
boban 588f5aee2f fix(ssh): best-effort audit + exception-safe switch/verify and modal run(); lock serverId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:16:54 +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 6f8f5dbc2b fix(ssh): graceful failure if password-disable throws post-switch; document privateKey-on-failure contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:04:56 +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 4f4b7225f5 fix(2fa): narrow TOTP verify exception handling; guard enrollment against a tampered secret
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:45:12 +02:00
boban 5b1e7e5168 fix(2fa): TwoFactorSetup back to auth layout; exception-safe User::verifyTotp at all call sites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:15 +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 c19a35e43d feat(2fa): generate recovery codes on first factor + open modal; persist Settings tab in URL
- TwoFactorSetup: move to app layout (hosts wire-elements/modal), guard on hasTotp() not hasTwoFactorEnabled(), redirect to settings and flash open_recovery_modal on first TOTP enrollment
- WebauthnKeys.register(): drop hasTwoFactorEnabled() guard (first key may be the sole factor), generate codes + dispatch openModal on first key enrollment
- Settings\Index: add #[Url] on $tab, add $openRecoveryModal flag, mount() reads the flash and lands on the security tab
- settings/index.blade.php: x-init block dispatches openModal when $openRecoveryModal is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:04:06 +02:00
boban a8616809f1 fix(services): cap live journal at 200 rows in a scrollable, auto-sticking box 2026-06-14 20:58:48 +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 82d5644767 feat(2fa): onboarding no longer forces 2FA (only password rotation) 2026-06-14 20:44:12 +02:00
boban 9a01bfedf7 feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor) 2026-06-14 20:38:36 +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 0b1b5357d2 fix(ui): set active server at mount only (race-free)
Revert the hydrate-on-every-request sync — writing the single global active-server
session on every async request (slow load(), polls, parallel tabs) is inherently
last-writer-wins and races. Setting it only at mount (once per deliberate
navigation) is race-free and fixes the visible drift. bfcache restore + multi-tab
remain inherent edges of the single-session model — documented in the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:43:24 +02:00
boban 7ea789c078 fix(ui): re-sync active server on every details-page request (bfcache-safe)
Add a hydrate() hook so Show re-asserts its server as active on the wire:poll tick
and any action — covering a wire:navigate bfcache restore that skips mount(),
without the earlier racy client-side POST. Idempotent + server-side, no race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:40:24 +02:00
boban 784566d06b fix(ui): keep the sidebar server-switcher in sync with the viewed server
Two "current server" notions diverged: the sidebar switcher (session active_server_id)
vs the server-details URL. Now viewing a server's details sets it active (sidebar
always matches the page), and switching the sidebar while on a details page navigates
to the chosen server (instead of reloading the old one). One current server, in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:24:56 +02:00
boban 2b04a18023 fix(webauthn): counter zero only when stored is zero + CLI clears keys
- ZeroTolerantCounterChecker only tolerates a 0 counter when the stored counter is
  also 0; a 0 after a non-zero counter is a rollback and is rejected.
- clusev:reset-admin --disable-2fa now also deletes the user's security keys, so a
  compromised key can't revive when 2FA is re-enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:47:07 +02:00
boban 44b87d78ba fix(webauthn): tolerate zero-counter authenticators + recheck 2FA on register
- Swap the strict counter checker for a zero-tolerant one so platform
  authenticators / synced passkeys (which always report counter 0) can assert,
  while non-zero counters still must strictly increase (clone detection).
- Guard registration (options + register) and the UI on hasTwoFactorEnabled, so a
  key is never created against a disabled/reset second factor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:44:24 +02:00
boban 139c376056 fix(webauthn): hash-indexed credential ids + reset keys on 2FA disable
- credential_id can exceed the unique-index key length; store it in TEXT and
  enforce uniqueness on an auto-derived sha256 hash column (lookups use the hash).
- Disabling 2FA now also deletes the user's security keys + backup codes, so
  re-enrolling starts clean and old factors never silently revive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:40:24 +02:00
boban 98a39e1257 fix(webauthn): validate the key label before the browser ceremony
options() now validates the label before returning creation options, so clicking
Add with a blank/invalid name aborts before navigator.credentials.create() and
never leaves an orphaned credential on the authenticator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:35:25 +02:00
boban 5704d6c0f7 fix(webauthn): re-serialize the credential after an assertion
Persist the updated PublicKeyCredentialSource (not just the sign_count column)
so the next assertion validates against the latest counter — keeping the
library's cloned-authenticator / counter-rollback detection effective.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:33:20 +02:00
boban fe5c190eb0 feat(webauthn): register/list/remove security keys in settings
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:29:51 +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 0cb9b073ce feat(webauthn): registration/assertion options + ceremony verification
WebauthnService builds creation/request options (rpId = active domain, ES256/RS256,
attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the
challenge in the session, and verifies attestation/assertion responses (allowed
origin = https://<rpId>, sign-count tracked). Option building + serialization are
unit-tested; the cryptographic verify path is browser-verified on a domain host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:24:10 +02:00
boban 54ebb49941 feat(webauthn): WebauthnService::available gate (domain + https)
WebAuthn is offered only with an active domain (the rpId) over HTTPS; bare-IP/HTTP
returns false so a ceremony never runs with an invalid IP rpId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:43 +02:00
boban 78b1017844 feat(webauthn): install web-auth/webauthn-lib + credential storage
Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:05 +02:00
boban 2ff2cc34c0 fix(auth): toaster on auth pages + audit email-link resets
Codex review follow-ups: extract the notify toaster into a shared partial and
include it in the auth layout too, so sendResetLink + recovery-code regenerate
actually surface their confirmation (the auth layout had no toaster). Record an
AuditEvent for password resets via the email-token path, matching the 2FA path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:07:52 +02:00
boban 3a67aa99ec fix(auth): rotate remember_token on reset + atomic recovery-code use
Address Codex security review:
- ForgotPassword + clusev:reset-admin now rotate remember_token, so a stolen
  remember-me cookie cannot survive a password reset (the email-token path
  already did this).
- useRecoveryCode reads/checks/removes under a row lock (DB transaction +
  lockForUpdate), so two concurrent requests can't both spend the same one-time
  code (replay).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:04:12 +02:00
boban f6a65827fd feat(auth): clusev:reset-admin CLI lockout fallback
Shell-only emergency recovery: resets the admin password (prints a random one if
omitted) and, with --disable-2fa, clears the 2FA secret + backup codes so the
operator can re-enroll. Requires server access — the safe last resort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:44 +02:00
boban abaed12b0b feat(auth): email reset-link path (gated on a configured mailer)
ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:05 +02:00
boban cb8b906aa8 feat(auth): forgot-password reset via 2FA or backup code
"Passwort vergessen?" on login -> a reset view that proves identity with a TOTP
or one-time backup code (no email needed), then sets a new password under the
min(12) mixedCase numbers policy. Rate-limited, generic messages (no account
enumeration), audited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:55:01 +02:00
boban 764e1e5e53 feat(auth): show + download + regenerate 2FA backup codes
2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:52:23 +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