Commit Graph

6 Commits (bc0691a730c8ab69abb54b4b525c05c392f99dfd)

Author SHA1 Message Date
boban f5f903046d fix(blade): garbled header on every signed-in page + private metrics channel (v0.4.1)
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>
2026-06-14 08:34:07 +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 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 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 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 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