Commit Graph

16 Commits (e6f6e7f8a46a0f9bdf2d2cfbbd616ab540878eaf)

Author SHA1 Message Date
boban d7156ddc96 fix(security): harden the update path — signed sentinel, symlink-safe root writes, .env 0600
Security audit (12-angle adversarial workflow) findings on the container→host boundary:
- .env was created world-readable (cp of a 644 .env.example, never chmod'd) exposing the APP_KEY
  that decrypts the whole fleet's SSH vault. Now chown clusev + chmod 0600 before any secret write.
- The root updater followed attacker-planted symlinks in the container-writable ./run (update-phase.json,
  update.log) → arbitrary root file write. Status writes now go via a temp + rename(2) (never follow a
  link); the update transcript moved to the repo root (not the ./run bind mount).
- The generated UPDATE_HMAC_KEY was dead. The app now HMAC-signs the update-request marker
  (config/clusev.php update_hmac_key; DeploymentService) and watch.sh verifies it before running a
  root update, so a stray/limited write to ./run can't drive one (a full container compromise holds
  the key, so this is a bar-raise + integrity check; the standing guarantee is the marker only ever
  re-installs the trusted remote's code, never attacker code).
- force_kv/set_kv wrote unescaped values into a sed replacement — a & | or newline in an operator's
  domain/email/port could corrupt .env or inject a sed command. Values are now CR/LF-stripped and
  sed-escaped, and the HTTP port is validated numeric/in-range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:25:10 +02:00
boban 72b82df7a1 fix(update): surface update failures instead of spinning forever
A dashboard-triggered update that failed on the host left the /update-progress
page spinning to a vague 10-minute timeout — no error was ever shown, because
nothing wrote a failure marker for the page to read. Fix: make failures visible
and prevent the credential-prompt hang class.

- docker/restart-sentinel/watch.sh: run update.sh under `timeout -k 30 1800` with
  its output tee'd to run/update.log; on ANY non-zero exit (update.sh or its
  exec'd install.sh) — and on the early compose-missing / updater-missing paths —
  write {"stage":"error"} to run/update-phase.json via write_update_error().
- update.sh: export GIT_TERMINAL_PROMPT=0 + GIT_HTTP_LOW_SPEED_* and wrap the pull
  in `timeout 300`, so a private-repo credential miss fails fast instead of
  hanging on a non-interactive prompt.
- DeploymentService::updatePhase(): whitelist the 'error' stage.
- update-progress.blade.php: add an error state (#js-status-error) + showError();
  the status-feed poll now stops and shows it on stage=error, marking the active
  phase red — instead of looping.
- lang/{en,de}/update.php: error_heading / error_hint / back_button.
- UpdateProgressTest: feed surfaces stage=error; page renders the error branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:58:32 +02:00
boban 05c34b81ed fix(update): make the update-progress screen survive a browser refresh
The self-update progress screen reset its elapsed timer to 0 and dropped the
phase checklist back to step one on every browser refresh — and the long image
build made it look like the update had restarted. Root causes: the timer and
phase state lived only in page JS (Date.now() at page load), and a
30s-before-page-load staleness guard rejected the real phase feed whenever a
stage had run longer than 30s (which the image build always does).

- DeploymentService: requestUpdate() now also writes a best-effort
  run/update-started.json {at,from}. New updateStartedAt()/updatePhase() expose
  the server-side start + current macro-stage; updatePhase() requires a positive
  timestamp so a corrupt or stale 'done' can never drive a premature finish.
  clearUpdateRequest() also removes the start marker.
- routes: /update-status.json delegates to updatePhase(); /update-progress
  passes startedAt/initialPhase/hasFeed. A stage is trusted only when tied to
  THIS update's start (startedAt) and written at/after it — a leftover stage from
  a previous run can no longer resume or trigger a premature completion.
- update-progress.blade.php: the elapsed timer is anchored to the server-side
  start (counts the real elapsed across a refresh); the checklist resumes at the
  live stage; the live feed is authoritative (corrects any time-heuristic
  overshoot) and the heuristic stays off once a real stage is known.

Verified: 28 UpdateProgressTest cases + full suite (426) green, pint clean, and a
headless browser load+reload mid-update keeps the timer counting and the phase in
place with zero console errors. Codex review: two premature-completion edge cases
found and fixed (timestamp validation + start-anchored freshness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:05:54 +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 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 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 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 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 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 a44e29426a feat(domain): change the panel domain from the dashboard (v0.4.0)
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>
2026-06-14 01:38:16 +02:00
boban c34ced0c0e fix(tls/ui): fully automatic TLS (status-only) + uniform hardening buttons
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>
2026-06-13 21:17:26 +02:00
boban 0f57074e6f chore(audit): full code sweep — fix open-redirect, drop dead code, clean docs
Full-codebase audit + automatic cleanup (no behavioural change for users).

Security
- ServerSwitcher: post-switch redirect is now reduced to a SAME-ORIGIN
  relative path. Rejects off-host referers, non-http(s) schemes
  (javascript:/data:), different ports (https://host:444/x -> /x), and
  protocol-relative tricks (//host, /\host). Closes a Referer open-redirect
  (Codex P2).
- EditCredential: a key passphrase is stored only for key auth, never for
  password auth.

Removed dead code
- FirewallService::status/allow/deny (+ orphaned clampPort)
- Server::auditEvents(), SshCredential::scopeActive(), unused 'bell' icon

Cleanups / improvements
- Files list + editor use a dedicated 'file' icon (was the audit icon)
- Import RuntimeException/Throwable instead of FQN; fix stale docblocks
  (FirewallService, HardeningService) and the Files delete comment
- Quote base64 in the sudo command path (defensive, consistent)
- Null-safe Auth::user()?->email in Settings; single domain() read in hasTls()

Release
- Bump 0.1.0 -> 0.1.1 + CHANGELOG entry

Verified: Pint clean, Codex review clean (no actionable regressions),
R12 browser check — 9 routes HTTP 200, 0 console errors, lazy pages loaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:54:26 +02:00
boban 05f8ce49b4 feat: add-server, apt updates, fail2ban config; clean modals; drop storage internals
Operator feedback: no way to add servers; irrelevant storage-internals text; fail2ban
only on/off; raw shell commands shown; no apt update/upgrade.

- Add server: CreateServer modal (name, IP/host, SSH port, user, password|key, optional
  label) on the Servers index → creates server + encrypted credential ATOMICALLY (DB
  transaction). Strict IP/hostname validation (filter_var + hostname; rejects 999.x, ':').
- System updates (Debian/Ubuntu only): MaintenanceService + SystemUpdate modal — shows the
  pending-update count (or "unbekannt" when undeterminable) and runs apt update && upgrade
  as root; gated to apt hosts.
- fail2ban configuration: Fail2banConfig modal — Sperrdauer / Max. Fehlversuche / Zeitfenster,
  written to a Clusev-owned jail.d drop-in (zz-clusev.local, last-wins; never touches the
  operator's jail.local/jails). Durations kept verbatim in fail2ban's native grammar
  (600, 10m, 1h 30m, -1). Reads the EFFECTIVE [DEFAULT] across files; refuses to save when the
  current policy couldn't be read (no overwrite with unseen defaults); reloads fail2ban only
  when already active (never starts it).
- Modals: removed the raw "Befehle (als root)" preview + raw stdout dumps from the hardening
  modal — clean German description + result only.
- System page: dropped the .env/Datenbank storage-internals callout (irrelevant to the user).

R15 — Codex gate: `codex review --uncommitted` run iteratively; fixed every finding across 9
rounds (fail2ban jail clobbering, section/precedence, composite/-1 durations, reload-starting-
inactive, read-failure propagation, glob exit code, apt-count failure, atomic server create,
IP validation) until clean — 0 security issues throughout. Live-verified on 10.10.90.162;
R12: Servers/Detail/System 200 / 0 console errors, modals open, hardening modal shows no commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:19:55 +02:00
boban 0b2dffe0de fix+feat(release/security): apt timeout, real version/release model, domain in DB not .env, R15
- 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>
2026-06-13 03:06:35 +02:00
boban a64bd39c6c feat(security): dashboard hardening, credential mgmt, system domain/TLS + channel, self-hardening
Security pivot — make server hardening, SSH access, firewall, dashboard domain/TLS
and the release channel controllable from the dashboard (no SSH needed), with guards
so a remote change can never lock the operator out.

Foundation
- ssh_credentials gains name + disabled_at + last_used_at; CredentialVault refuses a
  disabled credential. New key/value Setting model. FleetService::runPrivileged() /
  runPlain() — central sudo-aware exec (base64-wrapped sh -c), live-verified as root.

A · control-plane self-hardening
- SecurityHeaders middleware: env-aware CSP (allows the Vite dev origin in dev, strict
  same-origin in prod), X-Frame-Options DENY, nosniff, Referrer/Permissions-Policy,
  HSTS when secure. 2FA brute-force throttle (5/60s). install.sh sets
  SESSION_SAME_SITE=strict + EXPIRE_ON_CLOSE + SECURE_COOKIE (only behind TLS).

B · SSH credential management
- name/label on the access; a credential card on the server page with Bearbeiten /
  Sperren-Entsperren (kill-switch) / Löschen (R5), all audited.

C · server hardening from the dashboard (guards + confirmation)
- HardeningService (PermitRootLogin no, PasswordAuthentication no, fail2ban,
  unattended-upgrades) + FirewallService (UFW). HardeningAction modal previews the
  exact root commands before applying. GUARDS: refuse to disable password-login when
  Clusev itself logs in by password or no key exists; UFW opens the real sshd port +
  80/443 before enabling. Live-verified non-destructively (previews, the password
  guard refusing, ufw status read).

D+E · System page (/system)
- Dashboard Domain + Let's-Encrypt email (Setting) -> DeploymentService renders the
  matching Caddy site block (honest: stages the file + shows the reload command, never
  fakes TLS). Release channel (stable|beta|dev) configurable; Versions reads it.

Built largely by 4 parallel agents into disjoint files; shared files integrated + the
security-critical bits hardened by hand (the password-auth lock-out guard + env-aware CSP).

Verified (R12): /system + server detail + all 8 routes 200 / 0 console errors (CSP does
not break Livewire/Alpine/Vite); credential card + 5 hardening "Anwenden" buttons render;
the hardening modal opens with the command preview; System persists domain/channel +
renders valid Caddy config; runPrivileged runs as root + the vault refuses disabled creds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 02:25:23 +02:00