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>
- serve-request.test.sh: the push-fail case pointed origin at a dead https remote,
so _precheck's fetch failed first and the rollback path in _do_stage was never
reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote
(pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs;
also assert the tree is restored clean.
- clusev-release.sh: |\| true on the version extraction (defensive under pipefail).
- install.sh: make the release-unit install best-effort (if/then/warn) like the WG
block so a systemctl failure cannot abort the installer.
- .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep.
- docker/release/README.md: dev-watcher runbook (the manual end-to-end gate).
- ReleaseBridgeTest: assert a rejected target writes no request file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three things the operator asked for on the WireGuard page:
- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
active peers re-handshaking well within it, so no flapping; a disconnect now
shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
(auto-appended), no need to repeat the listen port.
Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
If the operator entered an endpoint without a port (e.g. "10.10.90.165"
instead of "10.10.90.165:51820"), that value was written verbatim into every
peer config. WireGuard requires the endpoint as host:port, so the WireGuard app
rejected the QR/file import as "keine gültige WireGuard-Konfiguration" — the
real cause of the invalid-config errors (the QR-size fix in 0.9.42 was secondary).
Normalise host-side: append the listen port when the endpoint has no colon, in
both _write_server_conf (setup — covers the dashboard and interactive paths) and
_set_endpoint (later changes). Verified: bare host -> host:port, host:port left
as-is, DNS host -> host:port; write-bridge smoke test still passes; shellcheck
clean. setup_endpoint_hint (de/en) now documents the host:port format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host handler `clusev-wg.sh serve-request` aborted under `set -euo pipefail`
BEFORE writing run/wg-result-<id>.json, so the dashboard waited ~30s and showed
"Keine Antwort vom Host" even though the systemd watcher was running correctly.
The write-bridge had therefore never actually worked on a real deployment.
Two root causes, both fixed:
1. Field extraction: each `id/action/name/endpoint/port/subnet` value is pulled
with `grep -oE | head | sed`. A field that is empty or absent (an "auto"
endpoint at setup; gate/add-peer/remove-peer payloads that carry no
endpoint/port/subnet) makes grep fail → with pipefail the whole pipeline
fails → `set -e` aborts the handler before _write_result. Appended `|| true`
to every extraction so a non-match yields "" instead of killing the handler.
2. Action dispatch: gate-up/gate-down/gate-ssh-on/off and set-endpoint/port/
subnet (and remove-peer) called helpers that run require_setup, which `die`s
(exit) when the tunnel isn't configured yet — aborting serve-request before
the reply. Each handler now runs in a subshell `( ... )` so an internal exit
becomes a caught non-zero and still yields a clean failure result.
(add-peer/setup were already isolated via their $(...) command substitution.)
Verified end-to-end: all ten actions now write a result for minimal/empty-field
payloads instead of timing out. New smoke test docker/wg/serve-request.test.sh
(root-bypassed, no WireGuard needed) guards both regressions. shellcheck clean,
348 PHP tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified dead-code + duplication cleanup over the WireGuard feature surface
(found via an audit workflow, every item adversarially re-verified by grep):
Dead code removed:
- lang keys not_configured_title / not_configured_body (both locales) — orphaned
when the unconfigured empty state became the in-page setup form (setup_title /
setup_intro). 0 references anywhere. de/en parity kept (83/83).
- WgTraffic::series() 'buckets' return key — written, never read by blade/route/test.
- blade $fmtB byte-formatter — byte-identical duplicate of the top-level $fmtBytes
already in scope; collapsed onto $fmtBytes.
DRY / readability (behavior-preserving):
- Index: PEER_NAME_RE constant (was the same regex literal at 5 sites), portInRange()
+ clampWindow() helpers (window default now self::WINDOWS[0]), openConfirm() helper
(5 confirm-modal openers shared one dispatch), onFlag() helper (2 apply handlers).
- WgBridge: validPort() helper (port check was duplicated verbatim).
- clusev-wg.sh: _write_server_conf() shared by both setup paths (the byte-identical
server bring-up was copy-pasted); single CIDR_RE constant for the 3 subnet checks;
fixed a stale header comment that still claimed SSH is "NEVER matched" (the v0.9.40
ssh-lock can match 22); documented the defence-in-depth re-validation in _set_*.
- WgStatus: documented server.pubkey is part of the /wg-status.json contract (kept).
No DB/migration changes (peer_name write-only column left intact — needs a
coordinated migration). 348 tests pass, shellcheck clean, Pint clean, R12 verified
in both the configured + unconfigured states (200, no console errors, no leaked
keys, traffic formatter renders 1.5 MB / 200 KB correctly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two dashboard additions to the WireGuard page:
- Peer config download: the show-once new-client view gets a "Download"
button next to the QR code (streams clusev-wireguard.conf) plus an
inline hint explaining QR-on-phone vs download-on-PC import.
- SSH lock (port 22): an optional toggle in WireGuard settings that walls
off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the
host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo +
wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over
the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot
unit's ConditionPathExists=wg0 and an in-function self-guard keep a
broken tunnel from ever applying the DROP; ssh_gate_apply re-validates
the subnet shape and fails open on a corrupt wg.env. Strong lock-out
warning shown inline AND in the confirm modal (recommend a backup peer).
New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears
both gates. Panel gate and SSH gate are independent toggles.
Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host
whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector
now reports gate.ssh. Adversarial lock-out review: all 8 failure modes
refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A checkout path containing shell syntax ($(), quotes) could otherwise inject
code into /usr/local/bin/clusev, which runs as root via 'sudo clusev update'.
Render the path through printf %q into an unquoted assignment; add a test that
a hostile path does not execute injected code.
- 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>
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>
- 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>
Validated install.sh end-to-end on a fresh Debian 13 VM (Docker apt install, clusev
user, image build, stack up, migrate, admin, MOTD) and a fully idempotent re-run.
Two follow-ups from that test:
- MOTD: replace the bare two-liner with an orange CLUSEV wordmark, the version, a LIVE
stack status ("X/Y Dienste aktiv", green/red via `docker compose ps` at each login),
the dashboard URL, a login hint (where the one-time credentials come from — no secret),
and management commands. install.sh substitutes __CLUSEV_URL__, __CLUSEV_VERSION__ and
__CLUSEV_COMPOSE__; the status lookup degrades to "unbekannt" and never fails a login.
- README: minimal Debian/Ubuntu images ship without `git`, so the documented `git clone`
failed before reaching the installer. The install block now installs git first and the
requirements note it. (curl is missing too but the installer self-installs it.)
Admin password strategy confirmed correct as-is: clusev:install generates a random
20-char password with must_change_password=true (forced rotation on first login) — kept
over a guessable default like "clusev", which would be a default-credential vuln and
would nullify the brute-force hardening.
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>
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>
Replace the page mock data with real reads from the target host via the
phpseclib SSH layer. New FleetService parses raw command output into the exact
shapes the Livewire pages already consume; all parsing forces LC_ALL=C and reads
/proc to stay locale-independent.
- app/Services/FleetService.php: metrics (cpu via two /proc/stat samples, mem via
/proc/meminfo, disk via df), full snapshot (identity/specs incl. virt+disk_gb,
volumes, interfaces from ip+/proc/net/dev, sshd/fail2ban/ufw hardening, authorized
keys), systemd units + journal, and an ls-based directory listing. One compound
command per read; connect/parse failures bubble up.
- clusev:poll-metrics command replaces the mock emitter in the dev supervisor:
polls every credentialed server, persists cpu/mem/disk/status, broadcasts
MetricsTicked(server) — unreachable boxes flagged offline, loop never dies.
- Pages wired with graceful failure (offline state, never a 500):
Dashboard (live cached metrics + notable units), Services (real units+journal),
Files (real listing + dir navigation via open/go/up), Server-Details (live
snapshot persisted onto the row + offline banner).
- WithFleetContext prefers a credentialed, non-offline server as the default.
- dualChart filters ticks by server name so the chart tracks the active host.
Verified live against a real Debian 13 box: metrics/services(91)/journal(25)/
files(navigable)/snapshot(5 ifaces, real hardening, real key) all parse correctly.
Credentials are stored encrypted in the vault — never in source.
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>
Manage clusev:mock-metrics as a supervisor program in the dev image so exactly
one emitter runs and survives container recreates (no stray exec -d processes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>