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