Prod metrics poller: docker-compose.prod.yml gains a `metrics` service
(clusev:poll-metrics --interval=15). The poller previously existed only in the
dev supervisor config, so on a customer install no process ever promoted a
server out of 'pending' (Initialisierung) or refreshed CPU/MEM — fresh
installs sat at 0% forever. The service inherits the hardened *image anchor.
Installer output: new run_logged() helper routes noisy steps (apt, docker
build/pull, compose up, migrate, caches) into install.log at the repo root —
TTY gets a spinner + "ok" lines, non-TTY gets start/ok lines, failures tail
the last 40 log lines and abort. Secret-bearing steps (APP_KEY, admin
password) deliberately stay out of the log. update.sh shares the same log via
CLUSEV_INSTALL_LOG and redirects git pull/verify-commit detail into it. The
log is initialized symlink-safely (mktemp + atomic rename -T) because the
repo root belongs to the unprivileged clusev user after install (Codex
finding); /install.log is gitignored so updates never conflict.
Server switcher: an empty fleet now renders an explanatory empty state with a
role-gated "Server hinzufügen" CTA instead of an empty dropdown panel.
Servers/groups headers: the raw <a> buttons (unconditional min-h-11) are
replaced with x-btn, aligning them with the h-8 accent button + status pill.
Firewall panel: the rules box is hidden for ANY inactive firewall (previously
only firewalld — an inactive ufw showed a misleading rules table via `ufw
show added`), with a tool-agnostic hint + Aktivieren action; the add-rule
trigger is gated on active. hardeningApplied now performs an in-place
security re-read (OS profile + hardening + firewall + fail2ban) instead of a
full-page skeleton reload, with a wire:loading cue and toggles disabled while
it runs — the state can no longer be re-toggled against stale data.
SshKeyProvision drops its redundant hardeningApplied (credentialChanged
already full-reloads).
Hardening modal: failure output limit 200 → 400 so the 202-char root-lockout
guard message renders unclipped; the root-login description now states that
PermitRootLogin no blocks both password and key logins.
Command palette: chords g o (Verfügbarkeit) + g b (Bedrohungen).
Codex-reviewed (one finding: symlink-following log init — fixed + re-verified
clean). 760 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multi-agent security audit of this branch flagged that the new
`extra_hosts: host.docker.internal:host-gateway` sat on the shared
x-app/x-image anchor, so reverb/queue/scheduler inherited it too. Only
the web `app` container serves the (admin-gated) "Clusev host" Docker
view; the workers run background code with no role gate. Combined with
the image's ability to decrypt the root HostCredential and open root SSH
via HostCredential::toServer()+FleetService, every worker held a network
path to a root shell on the host — defeating the manage-fleet gate that
only exists in the HTTP layer.
Not reachable as shipped (no job/scheduled code touches the host
credential), but a least-privilege regression. Move extra_hosts off the
anchor onto ONLY the `app` service (dev + prod). Verified: app resolves
host.docker.internal, queue/scheduler do not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MariaDB logged an [Warning] Aborted connection ... (Got an error/timeout
reading communication packets) for every DB connection PHP closed without
the COM_QUIT handshake — i.e. whenever a long-lived worker (queue/schedule/
reverb) was recycled, a php-fpm worker retired, or a container restarted.
These are protocol-normal, not a fault: Aborted_clients was 46 over ~24
days uptime (~2/day) with a non-persistent PDO pool and no connection leak.
They surface only because the mariadb:11 image ships log_warnings=2. Add
docker/mariadb/tuning.cnf (mounted in dev + prod) setting log_warnings=1 —
genuine errors still log, the expected-disconnect noise stops — plus a
higher net_read_timeout (120s) so a momentarily-stalled worker read isn't
aborted as aggressively. Verified: forcing app+queue restarts now produces
zero new "Aborted connection" lines. The DB is internal-network only, so
dropping the level-2 access-denied notes carries no real exposure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Docker page only ever targeted fleet servers, so an operator whose
containers run ON the Clusev host itself (the clusev stack + anything
else on that machine) saw nothing — the page has "nothing to do with
the fleet" from their point of view. It now has a target switch:
• Clusev host (default when a host SSH login exists) — lists the
containers on the machine Clusev runs on, reached over the local
Docker gateway with the existing encrypted HostCredential, the same
login the host terminal uses.
• the active fleet server — the previous behaviour, still available.
Mechanics:
- HostCredential::toServer() builds a TRANSIENT Server (never persisted)
carrying the host login; DockerService/FleetService run unchanged
against it. The logs modal resolves serverId 0 to this host server.
- VerifiesHostKey only pins (writes ssh_host_key) for persisted fleet
servers now; a transient host connection no longer spawns a junk
fleet row on every request. The host is the local gateway — no
network path to MITM — so skipping the pin there is safe.
- The `app` service (dev + prod compose) gains the
host.docker.internal:host-gateway mapping the terminal sidecar already
had, so the PHP layer can reach the host's sshd. Without it the app
container cannot resolve the host at all — the real reason host Docker
never worked.
- Absent runtime still renders the clean "not installed" state; a
host target with no login yet shows a setup hint.
Verified end-to-end in a browser: the page defaults to the host and
lists the full running stack (clusev-app/mariadb/redis/… + others) with
Logs/Restart/Stop actions. 748 tests green (5 new host-target tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the whole-codebase re-audit (deferred MEDIUM findings, Codex-reviewed):
- poller: CredentialVault enforces a credential's disabled_at lock only at connect(), but
PollMetrics reuses one long-lived SSH connection per server, so a credential disabled
mid-run kept streaming metrics over the already-open session. Add Server::withActiveCredential()
(whereHas credential whereNull disabled_at); the poller selects via it and prunes any cached
client whose server left the active set, so a revocation drops the server on the next tick and
closes its session promptly.
- prod compose: add security_opt no-new-privileges:true to every service + a generous pids_limit
(fork-bomb backstop). cap_drop/read_only are deliberately left out — they need a per-service
prod smoke test (nginx :80 bind, entrypoint chown) before enabling; documented inline.
- Caddyfile + compose: strengthen the TRUSTED_PROXY_CIDR guidance — an over-broad value in
external-TLS mode lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + ban.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security re-audit + adversarial red-team of the honeypot deception and
brute-force ban layer. Fixes (all with regression tests):
HoneypotController::trap
- BAN EVASION: keyed ban off getRealMethod() not isMethod('post') — the
latter honors _method / X-HTTP-Method-Override, so a POST spoofed as PUT
dodged the ban entirely. Now any non-safe verb to a decoy bans.
- REFLECTED-BAN vs EVASION: removed the Origin-based skip; the Origin header
is attacker-controlled, so it was a trivial universal ban-evasion (send a
foreign Origin) worse than the narrow griefing it prevented.
- UNMASK/500: mb_scrub all attacker-controlled meta (ua/query/path/creds) so
invalid UTF-8 can't throw JsonEncodingException on the array-cast column.
- Ban now runs FIRST and independently of the audit (separate guarded
try/catch) so a failing audit can never skip the ban; deceive() always 200.
- AUDIT DoS: per-IP hourly cap via atomic Cache::add(0)+increment.
DetectHoneytoken::trip
- Same guarded ban-first / audit-second structure + per-IP audit cap
(bounds an exempt or rotating source) + mb_scrub on the path.
BruteforceGuard::banNow
- Dedup the auth.ip_banned audit per IP+reason on a 60s window (was
full bantime): collapses the scanner flood without masking a legitimate
re-ban after an operator unban, and still audits distinct reasons.
Supply-chain / release / dashboard
- Pin prod base images (caddy/mariadb/redis) by @sha256 digest.
- Atomic branch+tag push in clusev-release.sh (no orphan untagged commit).
- Route github.ref_name through a validated $TAG env in ci-staging.yml.
- Strip inline credentials from the origin URL in set-repository-url.sh
(greedy match handles an @ inside the password).
- WireGuard peer name via Js::from in wire:click; settings badge shows the
real role; Threats pill/top_ip account for honeypot_login attempts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal resolves to an SSH login now (resolve never returns kind:'host'), so the
sidecar's local-shell path was unreachable:
- docker/terminal/server.js: drop startHost(), the spec.kind==='host' branch, the node-pty require
and HOST_SHELL/HOST_CWD constants — the sidecar only ever opens an SSH PTY.
- Dockerfile: drop the python3/make/g++ toolchain (only there to compile node-pty's native addon);
package.json: drop the node-pty dependency. Leaner, faster image.
- compose (dev+prod): drop the now-unused TERMINAL_HOST_CWD env and the .:/workspace:ro mount.
- remove grep-confirmed-unused lang keys (settings 2FA/stub-tab keys, accounts twofa/cannot_remove,
auth recovery_done/regenerate_confirm, common back/retry/more/loading, servers firewall/fail2ban
unavailable variants) and the unused x-server-item Blade component.
Verified: lean sidecar rebuilds + the server terminal still connects/runs; full suite 467 pass; all
pages 200 with no raw-key leaks and no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no
sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet
server — so it shows the real root@<host> prompt with full rights.
- HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets
the operator set host/port/user + password|key. The clear-text secret is never echoed
back; on edit a blank secret keeps the stored one, and switching auth method requires a
fresh secret (no password silently reused as a key).
- open('host') mints a kind=host token only when a login is configured, else opens the
setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted,
internal-net only) for kind=host, or 404 when unconfigured.
- compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host
sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly).
- rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line.
Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as
root runs commands, zero console errors; search filters; resolve returns the host spec.
15 terminal tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host terminal is a shell in the sidecar container; it opened in the read-only
repo mount (/workspace) with the prompt showing the random container id, so it read
like a stray container rather than "the Clusev host".
- give the sidecar a stable `hostname: clusev` (dev + prod) → prompt host is `clusev`
- land the host PTY in /home/node (a real, writable home) and set HOME to it, so the
shell opens at ~ like a normal terminal instead of /workspace
Server (SSH) terminals are unchanged. Verified in-browser: prompt reads node@clusev,
pwd is /home/node, HOME=/home/node, no container hash / no /workspace; server terminal
still connects and runs; zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dedicated /terminal page with a target rail: one terminal per fleet server (SSH)
plus one for the Clusev host itself. php-fpm cannot hold an interactive PTY, so a
small Node sidecar (ws + ssh2 + node-pty) bridges xterm.js to the PTY.
Flow: the Livewire page mints a single-use, 60s TerminalSession token and dispatches
it to an Alpine xterm island; the browser opens a same-origin WS (/terminal/ws,
proxied by nginx in dev / Caddy in prod to terminal:3000); the sidecar burns the
token at an internal resolve endpoint (shared-secret header, private network only)
for a connection spec, then opens an SSH PTY (decrypted credential, never sent to the
browser) or a local host PTY. Real shell = native Tab/Shift-Tab completion.
Security:
- token burned ATOMICALLY (single conditional UPDATE) — no double-open race
- resolve endpoint guarded by hash_equals shared secret, exempt from CSRF + PanelScheme
host enforcement (private net only), returns decrypted creds over the internal net
- sidecar enforces same-origin on the WS upgrade (hostname match, port-agnostic)
- single-use 60s tokens, swept daily so the table can't grow unbounded
Verified in-browser (R12): host + server terminals connect, accept keyboard input,
run commands, Tab-complete; cross-origin WS rejected; zero console errors. 9 feature
tests cover minting, the resolve spec, single-use burn, expiry, and locked creds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
One-command, idempotent production install per docs/install-update-design.md
(the "JETZT" phase). Onboarding (forced password change + 2FA) already ships
via EnsureSecurityOnboarded; the in-dashboard self-updater stays deferred to v1.x.
- install.sh: preflight -> idempotent secrets (set_kv never regenerates) ->
derive SITE_ADDRESS/APP_URL/REVERB_* from the single APP_DOMAIN knob -> build ->
up -> wait DB -> migrate + cache -> clusev:install. One-time admin password is
printed only on the terminal, never stored. Bare-IP and private-IP warnings.
- app/Console/Commands/Install.php: idempotent first admin (Str::password(20),
must_change_password); hard no-op once a user exists (INSTALL_LOCK idiom).
- docker/caddy/Caddyfile: one template, both modes; Reverb wss over /app/* and
/apps/* on the same address; admin-API-free, security headers. Validated for
bare-IP (:80) and domain (https) modes.
- docker-compose.prod.yml: Caddy is the only host-published service (80/443 +
HTTP/3); app/reverb/queue/mariadb lose host ports (internal net only);
CLUSEV_IMAGE indirection (locally-built tag, GHCR digest later); Redis requires
a password; caddy-data/caddy-config volumes persist ACME certs.
- .env.example: APP_DOMAIN/APP_SCHEME/ACME_EMAIL/SITE_ADDRESS/CLUSEV_IMAGE/
UPDATE_HMAC_KEY documented.
- README: real Clusev install/deploy guide (replaces Laravel boilerplate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>