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>
A single-file bind mount of the Caddyfile kept the original inode, so a
git pull that replaces the file never reached a running caddy, and up -d does
not recreate it for a content-only change — Caddy fixes (e.g. the 0.9.18
WebSocket fix) silently never deployed. Mount the docker/caddy directory
instead (reflects the live file) and force-recreate the caddy container on
every install/update so Caddyfile changes reliably take effect.
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>
Add update.sh (sudo ./update.sh): git pull --ff-only (never discards local
changes), self-update via re-exec if the script itself changed, then hand off
to the idempotent install.sh non-interactively. Sets safe.directory so a
root-run pull works on the clusev-owned tree.
Fix install.sh so a non-interactive re-run (an update) no longer wipes a
configured domain / ACME e-mail: both now default to the existing .env value
when not supplied. First install (empty = bare IP) is unchanged.
README documents 'sudo ./update.sh'; the MOTD shows the update command.
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>
- 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>
Dashboard "request certificate" button: DNS pre-check + internal SNI handshake that
triggers Caddy's on-demand TLS, with a persisted status, per-user throttle, and
external-TLS/bare-IP gating — all auto-expiring, never locking out the control plane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reliable modal triggers (@persist + spinner/error-toast across all views) and a
brute-force / rate-limiting / auth-DoS hardening pass (per-IP + per-account login
caps, constant-time login, TOTP replay protection, per-account 2FA backstops, a
global /livewire/update throttle, re-auth + SMTP-test throttles, and a fast-fail
SSH connect timeout) — all auto-expiring, never locking out the control plane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-user admin accounts, database-backed session management (list + per-user/global
revoke), auto-restart sentinel (no docker socket), SMTP configuration, audit-log
retention, plus the Settings split into per-tab components and the UI batch (image
preview, fail2ban bans modal, show-password, system polish).
BREAKING: sessions move from Redis to the database — all existing sessions are
invalidated on update (log in once again).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional pluggable 2FA (TOTP and/or WebAuthn, or off) + recovery-codes modal,
live capped journal, command-palette server search, settings tabs in URL,
fail2ban lone-box layout, and SSH-key auto-provision with safe password-login
disable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sidebar server-switcher syncs with the viewed server (race-free, mount-only).
Documented bfcache/multi-tab edge. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebAuthn/YubiKey as an optional login 2nd factor (web-auth/webauthn-lib), gated
on domain+HTTPS. TOTP + backup codes stay the required 2FA. Register/use/remove
keys in Settings + at the challenge; 2FA reset (UI + CLI) clears keys. Real
YubiKey end-to-end is browser-verified on a domain host. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Account recovery: 2FA backup codes (show-once/download/regenerate, usable at the
login challenge), forgot-password via 2FA or backup code (email link gated on a
configured mailer), and a clusev:reset-admin CLI lockout fallback. Resets rotate
remember_token; codes consumed atomically. WebAuthn/YubiKey remains a Phase-2
spec. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding + custom error pages: full favicon/PWA icon set wired into both
layouts, and branded bilingual error pages (403/404/419/429/500/503) replacing
Laravel's defaults with APP_DEBUG pinned off in prod — no raw status text or
stack traces. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop iOS zoom-on-focus: an unlayered @media (pointer: coarse) rule forces 16px
on input/textarea/select for touch devices (phone + tablet of any size), keeping
the dense desktop sizing for mouse pointers. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Responsive audit (R7) over all views at 375/768: hardened mobile overflow from
long panel domains (break-all/min-w-0) and banned IPv6 addresses in the fail2ban
list. Live pages were already overflow-free; only data-dependent edge cases
fixed. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-Details UX + hardening polish: uniform bordered buttons (danger-soft),
installed-only firewall/fail2ban grid, auto-updates as a neutral preference,
SSH-verified server creation with a "pending" (Initialisierung) status, and an
SSH key-only checklist hint — plus the fail2ban/UFW "nicht installiert"
false-negative fix. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes/hardenings on top of v0.4.0:
1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
every authenticated page (and left the page title empty). R12 missed it: the page still
returned 200 with no console error and the <title> comes from the Livewire component. Fixed
the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.
2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
(withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
anyone holding the bundled app key — including over a stale Caddy host after a domain change.
The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
channel is private.
Bump 0.4.0 -> 0.4.1; CHANGELOG.
Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Per operator feedback ("das soll automatisch sein, die Caddy-Config muss man nicht
sehen, nur 'SSL steht'"):
- TLS is fully automatic via Caddy (auto-issue + auto-renew + HTTP→HTTPS). The System
page now shows only the STATUS (domain + access URL) — the generated Caddy config and
the manual `caddy reload` command are gone, and the domain/email edit form is removed.
- The panel domain is an INSTALL-TIME value (APP_DOMAIN → config('clusev.domain')), so it
stays consistent with APP_URL, the Reverb (wss) endpoint and cookie security — none of
which can be re-derived at runtime (Caddy's site address + the browser's Reverb host are
fixed at install/build, and .env is never rewritten). DeploymentService is now read-only.
- Removed AppServiceProvider's runtime app.url override (it let a stale DB value shadow the
install-time URL). Migration drops the inert empty dashboard_domain/dashboard_email rows
(a real configured value is preserved as history; re-apply via install.sh — see CHANGELOG).
- Hardening checklist: all toggle buttons share one style (secondary/bordered) instead of
varying with state (R10).
Bump 0.2.0 -> 0.2.1 + CHANGELOG. Pint clean; Codex reviewed (the remaining note is a
deliberate install-time-domain tradeoff — a runtime/DB domain cannot be served by the
templated Caddy without a false "TLS active" status); R12 — system/server-show/dashboard
HTTP 200, 0 console errors, raw config + edit form gone, buttons uniform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(hardening): fail2ban/unattended apt installs failed because the 12s SSH timeout
aborted the package download. runPrivileged/runPlain take a $timeout; apt actions use
600s. Live-verified: fail2ban installs + is active/enabled on 10.10.90.162.
- feat(release): cut the foundation as the first real semantic release v0.1.0 (no more
-dev). CHANGELOG is Keep-a-Changelog BY VERSION; the Versions page parses it by version,
reads the newest git TAG as "latest release", and honestly compares installed vs latest
in the channel. Channels reduced to stable|beta (no user-facing dev); System clamps a
legacy value back to stable. Tagged v0.1.0.
- fix(domain/.env): the panel domain lives in the DATABASE (Settings); the app never
rewrites .env. AppServiceProvider overrides config('app.url') at runtime from the Setting
(guarded against a missing table); /system states this explicitly. Caddy config is a
standalone generated file; a Caddy reload is the only infra step.
- docs(arch): Caddy is the prod-only TLS reverse proxy IN FRONT of the app container's
nginx (Internet -> Caddy:443 -> nginx:80 -> php-fpm); no Caddy in dev.
- R15 (rules.md + CLAUDE.md): Codex must review every change for errors + security before
"done". The Codex CLI is not installed in this runtime (needs `sudo npm i -g @openai/codex`
+ `/codex:setup`); an independent adversarial security review of this diff returned CLEAN
(no vulnerabilities, no bugs) as the interim gate.
Verified (R12): /system + /versions 200 / 0 console errors; channels stable|beta only,
DB-not-.env callout; versions by-version with v0.1.0; app.url override resolves from the
Setting; fail2ban live install ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Service control now works with a sudo-PASSWORD credential (not only NOPASSWD):
FleetService::sudoFn() defines a remote priv() that feeds the vaulted password to
`sudo -S` over stdin (base64 over the encrypted channel, never on the argv) when not
root, else falls back to sudo -n. Verified LIVE on 10.10.90.162: cron restart/stop/
start executed for real (ActiveEnterTimestamp advanced to now, is-active=inactive
after stop, restarted clean), journal reads real entries via sudo.
- Login brand panel: removed fabricated telemetry — fake "24 Hosts erreichbar",
"cpu 31% mem 48% load 0.86", "clusev connect 10.10.90.0/24". Replaced with true
capability lines (agentless SSH/phpseclib, TOFU host-key pinning, 2FA, audit log,
AGPL). Only real claims now (per "nur eintragen was auch wirklich geht").
- New Version & Releases page (/versions, EN route per R13) with REAL data only:
version from config/clusev.php, build SHA + branch read from .git at runtime,
changelog parsed from a real CHANGELOG.md, real Gitea repo + AGPL license, honest
update path (deploy commands) — NO fake updater / stars / forks / CVEs / "update
available v2.5.0". Sidebar nav + tag/git-branch icons added.
- Dummy-data sweep (12-auditor workflow + adversarial verify): 1 confirmed finding —
removed the unused sine/cosine series() fake-sparkline generator in Dashboard.php;
also fixed a stale "static seed" chart comment and a stale "mock listing" comment;
derived the versions repo label from config (DRY). 5 false positives dismissed.
Verified (R12): /login + /versions + all 7 routes 200 / 0 console errors; login
fake-metrics gone + honest lines + fonts load; versions shows real version/build/
changelog/repo with zero fabricated stats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>