Commit Graph

11 Commits (6f8f5dbc2b12f48b9fd510ddbc9b1b2e2f63f025)

Author SHA1 Message Date
boban 139c376056 fix(webauthn): hash-indexed credential ids + reset keys on 2FA disable
- credential_id can exceed the unique-index key length; store it in TEXT and
  enforce uniqueness on an auto-derived sha256 hash column (lookups use the hash).
- Disabling 2FA now also deletes the user's security keys + backup codes, so
  re-enrolling starts clean and old factors never silently revive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:40:24 +02:00
boban 78b1017844 feat(webauthn): install web-auth/webauthn-lib + credential storage
Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:05 +02:00
boban f3e595ed45 feat(auth): store 2FA recovery codes (encrypted) on users
Add the two_factor_recovery_codes column (encrypted:array) + User helpers
replaceRecoveryCodes / recoveryCodes / hasRecoveryCodes / useRecoveryCode
(one-time consumption). Hidden from serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:48:21 +02:00
boban 5691051ad8 feat(i18n): bilingual foundation (DE/EN) + localize auth/dashboard/servers/fleet/account
Adds user-selectable German (default) + English, per new rule R16.

Foundation:
- config locale=de + fallback=de; User.locale column (+migration); SetLocale middleware
  (user → session → default; SUPPORTED=[de,en]); public /locale/{code} route; DE/EN
  switcher (x-lang-switch) in topbar + auth layout; <html lang> dynamic.
- Translation structure: lang/{de,en}/<group>.php (one group per feature + shared common),
  identical keys across both languages, :placeholder interpolation. Documented as R16 in
  rules.md + CLAUDE.md (R9 reconciled).
- Removed the "Warum Caddy" rationale block from the System page (per feedback).

Localized this pass (views + Livewire components, keys de↔en at parity):
- common, auth, dashboard, servers, services, files, audit, settings, system, versions.
- #[Title] attributes → dynamic title() (attributes can't call __()).

Remaining (next commit): modals components + a few modal views, shell (command palette,
nav-item, server-item, toaster), and backend service messages (app/Services/*, OsProfile).
App is fully functional in German throughout; English covers the groups above.

Boot-verified: /login HTTP 200 in DE + EN, titles + <html lang> localized, locale switch works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:36: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 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
boban b7fe66406c fix(live): poll-driven metrics (chart + gauges), bigger donuts, drop fake fleet
Addresses "chart/gauges don't update" and "design": the visible live layer no
longer depends on the Reverb WS path (which was unreliable through the dev NAT).

- Poller is now the single SSH metrics source: FleetService::applyMetrics writes
  the latest reading + a rolling cpu/mem history into the cache (and the DB).
- Dashboard reads latest + history from cache (no SSH on web render); the big
  chart and KPI sparklines render real history; wire:poll.10s refreshes them.
- Server-Details gauges read the poller-updated row via wire:poll.10s; the donut
  rings get a size="lg" variant (h-24) so they read clearly + animate.
- FleetSeeder no longer seeds a fake fleet (those servers had no credentials and
  showed no data). It seeds one real server from CLUSEV_DEMO_SSH_* env vars, or
  an empty fleet otherwise. Existing fake servers removed from the demo DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:38:51 +02:00
boban 5a9b6d317f fix(ssh): address adversarial review — host-key pinning, escaping, parsers
Resolve all 12 confirmed findings from the SSH-integration review.

Security:
- TOFU host-key pinning (new VerifiesHostKey trait + servers.ssh_host_key column):
  SshClient/Sftp record the server host key on first connect and refuse the
  connection on any later mismatch — phpseclib does not pin host keys itself.
- PollMetrics keeps one long-lived SSH connection per server and reuses it across
  ticks (a single login, not one per interval) — avoids fail2ban/auth-log churn;
  dead connections are dropped and re-established next tick.
- @js() escaping for every server-controlled value interpolated into wire:click
  (file names, paths, service names, SSH-key comments/fingerprints) — prevents
  JS-string breakage / injection from untrusted remote data.

Correctness:
- parseKeys regex makes the key comment optional (keys without a comment were
  silently dropped).
- parseVolumes pops the three trailing single-token df fields and rejoins the
  rest, so mount points containing spaces parse correctly.
- Sftp gains a disconnect() method to match SshClient (explicit cleanup).

Rules:
- Services "Start" -> "Starten" (R9, German).
- Files breadcrumb buttons get min-w-11 + padding (R7, >=44px touch target).

Verified live: host key stored + mismatch refused + correct key reconnects;
poller reuse polls ok; all pages still render real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:55:27 +02:00
boban b3e5d3a024 feat: auth + 2FA (login wall + TOTP + forced onboarding)
- Custom Livewire auth (full-page, R1/R2): Login (rate-limited), TwoFactorChallenge,
  TwoFactorSetup (QR via pragmarx/google2fa-qrcode), PasswordChange.
- Guard: panel routes behind auth + EnsureSecurityOnboarded middleware — forces
  password rotation, then 2FA enrolment, before the panel unlocks.
- User: two_factor_secret (encrypted), two_factor_confirmed_at, must_change_password.
- Centered auth layout, POST logout, dynamic sidebar user + 2FA status.
- Seeded admin with a one-time password + must_change_password.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:57:02 +02:00
boban 506fe4044a feat: v1 data models + routes (Server/AuditEvent/SshCredential)
- Server (uuid route key R11; cpu/mem/disk/specs/status/uptime), AuditEvent
  (actor/action/target + user/server relations), SshCredential (encrypted vault:
  secret/passphrase cast as 'encrypted', hidden). Migrations + FleetSeeder (4 servers, 4 events).
- routes/web.php: full-page Livewire routes for dashboard, servers (index +
  {server:uuid} show), services, files, audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 06:25:47 +02:00
boban 53f7309c0b feat: scaffold — Dockerized Laravel 13 + Livewire 3 + Tailwind 4 + Reverb
Working dev stack on :80 (Freigabe step 5: app boots, page renders, Vite HMR runs):
- Multi-stage Dockerfile (php8.3-fpm + nginx + supervisor + node). Dev runs
  php-fpm + nginx + vite in ONE app container; prod target bakes vendor + assets.
- docker-compose.yml (dev, bind-mount) + docker-compose.prod.yml (GHCR image).
  Env-driven ports/UID (APP_PORT, VITE_PORT, REVERB_HOST_PORT, DB_PORT,
  HOST_UID/HOST_GID) — nothing hardcoded. MariaDB only on 127.0.0.1:3306.
- Laravel 13, Livewire 3.8 (class-based, no Volt), wire-elements/modal 2,
  phpseclib 3, laravel/reverb 1. composer platform pinned to php 8.3.
- Tailwind v4 @theme tokens ("Tactical Terminal") in app.css; Vite remote HMR
  (host 0.0.0.0, hmr.host 10.10.90.136).
- Dashboard as a full-page Livewire route (/) in layouts/app; §5 folder skeleton.
- Docs: rules.md R1-R11 (incl. R11 UUID-in-URLs) + CLAUDE.md updated for
  Laravel 13, clusev/ root, port 80, vite-in-app, env-driven config.

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