From a64bd39c6c9a44d3df8cb0b13a8125f98dcb3693 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 13 Jun 2026 02:25:23 +0200 Subject: [PATCH] feat(security): dashboard hardening, credential mgmt, system domain/TLS + channel, self-hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Middleware/SecurityHeaders.php | 80 ++++++++ app/Livewire/Auth/TwoFactorChallenge.php | 12 ++ app/Livewire/Modals/EditCredential.php | 9 + app/Livewire/Modals/HardeningAction.php | 132 ++++++++++++ app/Livewire/Servers/Show.php | 79 +++++++ app/Livewire/System/Index.php | 130 ++++++++++++ app/Livewire/Versions/Index.php | 11 +- app/Models/Setting.php | 34 +++ app/Models/SshCredential.php | 13 ++ app/Services/DeploymentService.php | 134 ++++++++++++ app/Services/FirewallService.php | 125 +++++++++++ app/Services/FleetService.php | 43 ++++ app/Services/HardeningService.php | 194 ++++++++++++++++++ app/Support/Ssh/CredentialVault.php | 4 + bootstrap/app.php | 4 +- ...add_name_and_status_to_ssh_credentials.php | 26 +++ ...026_06_13_010001_create_settings_table.php | 24 +++ install.sh | 6 + resources/views/components/sidebar.blade.php | 1 + .../livewire/modals/edit-credential.blade.php | 5 + .../modals/hardening-action.blade.php | 60 ++++++ .../views/livewire/servers/show.blade.php | 87 +++++++- .../views/livewire/system/index.blade.php | 119 +++++++++++ routes/web.php | 2 + 24 files changed, 1325 insertions(+), 9 deletions(-) create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 app/Livewire/Modals/HardeningAction.php create mode 100644 app/Livewire/System/Index.php create mode 100644 app/Models/Setting.php create mode 100644 app/Services/DeploymentService.php create mode 100644 app/Services/FirewallService.php create mode 100644 app/Services/HardeningService.php create mode 100644 database/migrations/2026_06_13_010000_add_name_and_status_to_ssh_credentials.php create mode 100644 database/migrations/2026_06_13_010001_create_settings_table.php create mode 100644 resources/views/livewire/modals/hardening-action.blade.php create mode 100644 resources/views/livewire/system/index.blade.php diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..38d9ae6 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,80 @@ +headers; + + $headers->set('X-Frame-Options', 'DENY'); + $headers->set('X-Content-Type-Options', 'nosniff'); + $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $headers->set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=(), interest-cohort=()' + ); + + // Livewire/Alpine need inline + eval; Reverb rides ws/wss. + $scriptSrc = ["'self'", "'unsafe-eval'", "'unsafe-inline'"]; + $styleSrc = ["'self'", "'unsafe-inline'"]; + $connectSrc = ["'self'", 'ws:', 'wss:']; + $fontSrc = ["'self'"]; + + // In dev, Vite serves the bundle + HMR from a SEPARATE origin (e.g. + // http://host:5173). A same-origin CSP would block it and break the app, + // so allow the running dev-server origin (from public/hot) outside prod. + // In production assets are same-origin (/build) and this branch is inert. + if (! app()->isProduction() && is_file(public_path('hot'))) { + $dev = trim((string) file_get_contents(public_path('hot'))); + if ($dev !== '') { + $devWs = preg_replace('#^http#', 'ws', $dev); + array_push($scriptSrc, $dev); + array_push($styleSrc, $dev); + array_push($fontSrc, $dev); + array_push($connectSrc, $dev, $devWs); + } + } + + $headers->set('Content-Security-Policy', implode('; ', [ + "default-src 'self'", + 'script-src '.implode(' ', $scriptSrc), + 'style-src '.implode(' ', $styleSrc), + 'connect-src '.implode(' ', $connectSrc), + "img-src 'self' data:", + 'font-src '.implode(' ', $fontSrc), + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ])); + + // Only assert HSTS over an already-secure connection so we never + // pin clients to HTTPS on a plain-HTTP dev/LAN deployment. + if ($request->isSecure()) { + $headers->set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains' + ); + } + + return $response; + } +} diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 19f0603..584dd8e 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -4,6 +4,7 @@ namespace App\Livewire\Auth; use App\Models\User; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -29,6 +30,14 @@ class TwoFactorChallenge extends Component { $this->validate(); + $key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip()); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'code' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.', + ]); + } + $user = User::find(session('2fa.user')); if (! $user || ! $user->hasTwoFactorEnabled()) { @@ -39,9 +48,12 @@ class TwoFactorChallenge extends Component $valid = (new Google2FA())->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); if (! $valid) { + RateLimiter::hit($key, 60); throw ValidationException::withMessages(['code' => 'Ungültiger Code.']); } + RateLimiter::clear($key); + $remember = (bool) session('2fa.remember'); session()->forget(['2fa.user', '2fa.remember']); diff --git a/app/Livewire/Modals/EditCredential.php b/app/Livewire/Modals/EditCredential.php index 55644ef..2c8a12a 100644 --- a/app/Livewire/Modals/EditCredential.php +++ b/app/Livewire/Modals/EditCredential.php @@ -15,6 +15,8 @@ class EditCredential extends ModalComponent { public int $serverId; + public string $name = ''; + public string $username = ''; public string $authType = 'password'; @@ -30,6 +32,7 @@ class EditCredential extends ModalComponent $this->serverId = $serverId; if ($cred = Server::find($serverId)?->credential) { + $this->name = $cred->name ?? ''; $this->username = $cred->username; $this->authType = $cred->auth_type; // secret/passphrase are never pre-filled (encrypted at rest) @@ -56,8 +59,14 @@ class EditCredential extends ModalComponent return; } + if (mb_strlen(trim($this->name)) > 120) { + $this->error = 'Name darf höchstens 120 Zeichen lang sein.'; + + return; + } $server->credential()->updateOrCreate([], [ + 'name' => trim($this->name) !== '' ? trim($this->name) : null, 'username' => trim($this->username), 'auth_type' => $this->authType === 'key' ? 'key' : 'password', 'secret' => $this->secret, diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php new file mode 100644 index 0000000..b8abf90 --- /dev/null +++ b/app/Livewire/Modals/HardeningAction.php @@ -0,0 +1,132 @@ +`), notifies, asks the page to + * reload the snapshot, and — on success — closes. + * + * Action keys: ssh_root, ssh_password, fail2ban, unattended, firewall. + */ +class HardeningAction extends ModalComponent +{ + public int $serverId; + + public string $action; + + /** Optional TCP port (reserved for future allow/deny firewall actions). */ + public ?int $port = null; + + public string $heading = ''; + + public string $description = ''; + + public string $preview = ''; + + /** Result of the apply run, set after the operator confirms. */ + public bool $done = false; + + public bool $ok = false; + + public string $output = ''; + + /** Set when the action key is unknown / server vanished — blocks apply. */ + public ?string $error = null; + + public function mount(int $serverId, string $action, ?int $port = null): void + { + $this->serverId = $serverId; + $this->action = $action; + $this->port = $port; + + $server = Server::find($serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + if ($action === 'firewall') { + $this->heading = 'Firewall (UFW) aktivieren'; + $this->description = 'Öffnet zuerst den SSH-Port und 80/443, dann wird UFW aktiviert — die laufende SSH-Sitzung bleibt erreichbar.'; + $this->preview = app(FirewallService::class)->enablePreview($server); + } else { + $hardening = app(HardeningService::class); + $this->heading = $hardening->title($action); + $this->description = $hardening->description($action); + $this->preview = $hardening->preview($server, $action); + } + } catch (Throwable $e) { + $this->error = $e->getMessage(); + } + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function apply(): void + { + if ($this->error !== null || $this->done) { + return; + } + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + $result = $this->action === 'firewall' + ? app(FirewallService::class)->enable($server) + : app(HardeningService::class)->apply($server, $this->action); + } catch (Throwable $e) { + $this->done = true; + $this->ok = false; + $this->output = $e->getMessage(); + + return; + } + + $this->done = true; + $this->ok = $result['ok']; + $this->output = $result['output'] !== '' ? $result['output'] : ($result['ok'] ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.'); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'harden.'.$this->action, + 'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'), + 'ip' => request()->ip(), + ]); + + if ($this->ok) { + $this->dispatch('hardeningApplied'); + $this->dispatch('notify', message: $this->heading.' angewendet.'); + } else { + $this->dispatch('notify', message: $this->heading.' fehlgeschlagen.'); + } + } + + public function render() + { + return view('livewire.modals.hardening-action'); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 5b7a24b..71b7170 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -2,8 +2,10 @@ namespace App\Livewire\Servers; +use App\Models\AuditEvent; use App\Models\Server; use App\Services\FleetService; +use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; @@ -166,6 +168,83 @@ class Show extends Component return $this->redirect(route('files.index'), navigate: true); } + /** + * Toggle the credential between aktiv/gesperrt (sets/clears disabled_at). + * The CredentialVault already refuses a disabled credential, so this is the + * operator's kill-switch for a deposited login without deleting it. + */ + public function toggleCredential(): void + { + $cred = $this->server->credential; + if (! $cred) { + return; + } + + $disable = ! $cred->disabled; + $cred->forceFill(['disabled_at' => $disable ? now() : null])->save(); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $this->server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $disable ? 'credential.disable' : 'credential.enable', + 'target' => $this->server->name.' · '.$cred->username, + 'ip' => request()->ip(), + ]); + + $this->server->refresh(); + $this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.'); + } + + /** + * Delete the credential (R5): opens the confirm modal, which writes the + * AuditEvent and re-dispatches `credentialDeleted`. + */ + public function confirmDeleteCredential(): void + { + $cred = $this->server->credential; + if (! $cred) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => 'Zugang löschen', + 'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.", + 'confirmLabel' => 'Löschen', + 'danger' => true, + 'icon' => 'trash', + 'auditAction' => 'credential.delete', + 'auditTarget' => $this->server->name.' · '.$cred->username, + 'serverId' => $this->server->id, + 'event' => 'credentialDeleted', + 'params' => [], + 'notify' => 'Zugang gelöscht.', + ], + ); + } + + /** Applies the confirmed credential deletion, then refreshes the row. */ + #[On('credentialDeleted')] + public function deleteCredential(): void + { + $this->server->credential()->delete(); + $this->server->refresh(); + } + + /** + * A hardening/firewall action was applied -> re-pull the snapshot so the + * checklist + firewall row reflect the new state. + */ + #[On('hardeningApplied')] + public function reloadSnapshot(FleetService $fleet): void + { + $this->server->refresh(); + $this->ready = false; + $this->load($fleet); + } + public function render() { return view('livewire.servers.show'); diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php new file mode 100644 index 0000000..7d77ebd --- /dev/null +++ b/app/Livewire/System/Index.php @@ -0,0 +1,130 @@ + 'Nur getestete, freigegebene Releases. Empfohlen für den Produktivbetrieb.', + 'beta' => 'Vorabversionen zum Testen neuer Funktionen. Kann instabil sein.', + 'dev' => 'Entwicklungs-Builds direkt aus dem Haupt-Branch. Nur für Entwicklung.', + ]; + + public string $domain = ''; + + public string $email = ''; + + public string $channel = 'stable'; + + public function mount(DeploymentService $deployment): void + { + $this->domain = (string) ($deployment->domain() ?? ''); + $this->email = (string) ($deployment->email() ?? ''); + $this->channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; + } + + /** Persist domain + email, then (re)render the Caddy site config for review. */ + public function saveDomain(DeploymentService $deployment): void + { + $data = $this->validate([ + 'domain' => ['nullable', 'string', 'max:253', 'regex:/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'], + 'email' => ['nullable', 'email', 'max:255', 'required_with:domain'], + ], [ + 'domain.regex' => 'Bitte eine gültige Domain angeben (z. B. panel.example.com).', + 'email.required_with' => 'Bei gesetzter Domain ist eine Admin-E-Mail für Let\'s Encrypt Pflicht.', + ]); + + $deployment->setDomain($data['domain'] ?? null); + $deployment->setEmail($data['email'] ?? null); + + $this->domain = (string) ($deployment->domain() ?? ''); + $this->email = (string) ($deployment->email() ?? ''); + + $deployment->writeCaddyConfig(); + + $this->audit('system.settings_updated', $this->domain !== '' ? $this->domain : 'bare-ip'); + + $this->dispatch('notify', message: $this->domain !== '' + ? 'Domain gespeichert. Caddy-Konfiguration neu erzeugt.' + : 'Bare-IP-Modus gespeichert. Caddy-Konfiguration neu erzeugt.'); + } + + /** Release channel changes are audited (confirmation via the shared modal). */ + public function confirmChannel(string $channel): void + { + if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => 'Release-Kanal wechseln', + 'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.', + 'confirmLabel' => 'Wechseln', + 'danger' => false, + 'icon' => 'git-branch', + 'auditAction' => 'system.settings_updated', + 'auditTarget' => 'release_channel='.$channel, + 'event' => 'channelChanged', + 'params' => ['channel' => $channel], + 'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.', + ], + ); + } + + #[On('channelChanged')] + public function applyChannel(string $channel): void + { + if (! array_key_exists($channel, self::CHANNELS)) { + return; + } + + Setting::put('release_channel', $channel); + $this->channel = $channel; + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function render(DeploymentService $deployment) + { + return view('livewire.system.index', [ + 'hasTls' => $this->domain !== '', + 'caddyConfig' => $deployment->renderCaddyConfig(), + 'reloadHint' => $deployment->reloadHint(), + 'channels' => self::CHANNELS, + ]); + } +} diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index fda413d..00b369a 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -2,6 +2,7 @@ namespace App\Livewire\Versions; +use App\Models\Setting; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -22,7 +23,13 @@ class Index extends Component public function checkUpdates(): void { $this->lastChecked = now()->format('H:i'); - $this->dispatch('notify', message: 'Entwicklungsversion — kein Release-Kanal konfiguriert.'); + + $channel = Setting::get('release_channel', config('clusev.channel')); + $repository = config('clusev.repository'); + + $this->dispatch('notify', message: $repository + ? 'Kanal: '.$channel.' — Quelle: '.$repository + : 'Entwicklungsversion — kein Release-Kanal konfiguriert.'); } /** Resolve the current commit from .git without needing the git binary. */ @@ -99,7 +106,7 @@ class Index extends Component { return view('livewire.versions.index', [ 'version' => config('clusev.version'), - 'channel' => config('clusev.channel'), + 'channel' => Setting::get('release_channel', config('clusev.channel')), 'repository' => config('clusev.repository'), 'license' => config('clusev.license'), 'build' => $this->build(), diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..363c259 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,34 @@ + static::query()->find($key)?->value); + + return $value ?? $default; + } + + public static function put(string $key, ?string $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget("setting:{$key}"); + } +} diff --git a/app/Models/SshCredential.php b/app/Models/SshCredential.php index 27ea1fc..0d528a1 100644 --- a/app/Models/SshCredential.php +++ b/app/Models/SshCredential.php @@ -16,6 +16,8 @@ class SshCredential extends Model protected $casts = [ 'secret' => 'encrypted', 'passphrase' => 'encrypted', + 'disabled_at' => 'datetime', + 'last_used_at' => 'datetime', ]; protected static function booted(): void @@ -27,4 +29,15 @@ class SshCredential extends Model { return $this->belongsTo(Server::class); } + + /** A "gesperrt" (disabled) credential is kept on record but refused by the vault. */ + public function getDisabledAttribute(): bool + { + return $this->disabled_at !== null; + } + + public function scopeActive($query) + { + return $query->whereNull('disabled_at'); + } } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php new file mode 100644 index 0000000..7fab980 --- /dev/null +++ b/app/Services/DeploymentService.php @@ -0,0 +1,134 @@ +domain() !== null && $this->domain() !== ''; + } + + /** + * Render the Caddy site block for the configured domain, mirroring the shape of + * docker/caddy/Caddyfile: a global `email` block + a site address that triggers + * automatic Let's Encrypt TLS, with the same Reverb (/app/*, /apps/*) and app + * upstreams and the same conservative hardening header set. + * + * With a domain set: `https://` → auto-TLS on :443, HTTP auto-redirect. + * Without a domain: `:80` → plain HTTP on the bare IP (no cert issued). + */ + public function renderCaddyConfig(): string + { + $domain = $this->domain(); + $email = $this->email(); + + $siteAddress = $domain ? 'https://'.$domain : ':80'; + // Caddy needs a non-empty ACME email even in bare-IP mode, where no cert is + // issued so the value is simply unused — mirror the compose default. + $acmeEmail = $email ?: 'admin@localhost'; + + return << -> auto Let's Encrypt, :443, HTTP auto-redirect + # :80 -> plain HTTP on the bare IP, no cert + + { + \temail {$acmeEmail} + } + + {$siteAddress} { + \t@reverb path /app/* /apps/* + \treverse_proxy @reverb reverb:8080 + + \treverse_proxy app:80 + + \tencode zstd gzip + + \theader { + \t\tX-Content-Type-Options nosniff + \t\tX-Frame-Options DENY + \t\tReferrer-Policy strict-origin-when-cross-origin + \t\t-Server + \t} + } + CADDY; + } + + /** + * Write the rendered site config into the app's storage dir. This is a staging + * file the operator can mount/copy into Caddy's /config — it does NOT reload the + * live proxy (separate container). Returns the absolute path written. + */ + public function writeCaddyConfig(): string + { + $dir = storage_path('app/caddy'); + + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + $path = $dir.'/'.self::CONFIG_FILENAME; + file_put_contents($path, $this->renderCaddyConfig().PHP_EOL); + + return $path; + } + + /** Absolute path writeCaddyConfig() targets (no write performed). */ + public function configPath(): string + { + return storage_path('app/caddy/'.self::CONFIG_FILENAME); + } + + /** + * Operator note: the app cannot reload Caddy. After writing, reload is an infra + * step inside the caddy container. + */ + public function reloadHint(): string + { + return 'caddy reload --config /config/'.self::CONFIG_FILENAME; + } +} diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php new file mode 100644 index 0000000..cd82be9 --- /dev/null +++ b/app/Services/FirewallService.php @@ -0,0 +1,125 @@ +} + */ + public function status(Server $server): array + { + $res = $this->fleet->runPrivileged($server, 'ufw status verbose 2>&1'); + $raw = $res['output']; + + $active = (bool) preg_match('/Status:\s*active/i', $raw); + + $rules = []; + foreach (preg_split('/\R/', $raw) as $line) { + $line = trim($line); + // rule lines carry an action token (ALLOW/DENY/REJECT/LIMIT) + if ($line !== '' && preg_match('/\b(ALLOW|DENY|REJECT|LIMIT)\b/i', $line)) { + $rules[] = $line; + } + } + + return ['active' => $active, 'raw' => $raw, 'rules' => $rules]; + } + + /** + * The exact command(s) enable() will run, with the detected sshd port + * already substituted — shown to the operator before applying. + */ + public function enablePreview(Server $server): string + { + return $this->enableScript($this->sshPort($server)); + } + + /** + * GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never + * enable a firewall that would drop the current SSH session. + * + * @return array{ok: bool, output: string, preview: string} + */ + public function enable(Server $server): array + { + $port = $this->sshPort($server); + $preview = $this->enableScript($port); + $res = $this->fleet->runPrivileged($server, $this->enableScript($port)); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** + * Allow a TCP port through the firewall. + * + * @return array{ok: bool, output: string, preview: string} + */ + public function allow(Server $server, int $port): array + { + $port = $this->clampPort($port); + $preview = "ufw allow {$port}/tcp"; + $res = $this->fleet->runPrivileged($server, $preview); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** + * Deny a TCP port through the firewall. + * + * @return array{ok: bool, output: string, preview: string} + */ + public function deny(Server $server, int $port): array + { + $port = $this->clampPort($port); + $preview = "ufw deny {$port}/tcp"; + $res = $this->fleet->runPrivileged($server, $preview); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** Build the guarded enable script for a given sshd port. */ + private function enableScript(int $port): string + { + return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable"; + } + + /** + * Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls + * back to 22 when nothing is set explicitly. + */ + private function sshPort(Server $server): int + { + $res = $this->fleet->runPrivileged( + $server, + 'grep -rhiE "^[[:space:]]*Port[[:space:]]+[0-9]+" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null ' + .'| awk \'{print $2}\' | head -1' + ); + + $port = (int) trim($res['output']); + + return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22; + } + + /** Keep a port in the valid TCP range. */ + private function clampPort(int $port): int + { + return max(1, min(65535, $port)); + } +} diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 72b2f8c..ea92274 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -54,6 +54,49 @@ class FleetService return 'priv() { if [ "$(id -u)" = 0 ]; then "$@"; else '.$branch.'; fi; }; '; } + /** + * Run an arbitrary command as root (sudo password / direct root), returning + * [ok, output]. The command runs under `sh -c` so pipes/redirects work; it is + * passed base64-encoded so quoting/escaping is never an issue. + * + * @return array{ok: bool, output: string} + */ + public function runPrivileged(Server $server, string $command): array + { + $b64 = base64_encode($command); + $ssh = $this->client($server); + + try { + [$out, $code] = $ssh->run( + 'export LC_ALL=C; '.$this->sudoFn($server). + 'priv sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1' + ); + + return ['ok' => $code === 0, 'output' => trim($out)]; + } finally { + $ssh->disconnect(); + } + } + + /** + * Run an unprivileged command as the SSH user, returning [ok, output]. + * + * @return array{ok: bool, output: string} + */ + public function runPlain(Server $server, string $command): array + { + $b64 = base64_encode($command); + $ssh = $this->client($server); + + try { + [$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1'); + + return ['ok' => $code === 0, 'output' => trim($out)]; + } finally { + $ssh->disconnect(); + } + } + /** Split a marker-delimited compound output into [section => body]. */ private function sections(string $out): array { diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php new file mode 100644 index 0000000..0f4624b --- /dev/null +++ b/app/Services/HardeningService.php @@ -0,0 +1,194 @@ + 'SSH-Root-Login deaktivieren', + 'ssh_password' => 'SSH-Passwort-Login deaktivieren', + 'fail2ban' => 'fail2ban installieren', + 'unattended' => 'Automatische Updates aktivieren', + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; + } + + /** Short description of what an action does (shown above the command preview). */ + public function description(string $action): string + { + return match ($action) { + 'ssh_root' => 'Schreibt PermitRootLogin no als Drop-in und lädt den SSH-Dienst neu. Direkter Root-Login über SSH wird unterbunden.', + 'ssh_password' => 'Schreibt PasswordAuthentication no als Drop-in und lädt den SSH-Dienst neu. Anmeldung ist danach nur noch per SSH-Key möglich.', + 'fail2ban' => 'Installiert fail2ban und startet den Dienst. Wiederholte fehlgeschlagene Logins werden automatisch gesperrt.', + 'unattended' => 'Installiert unattended-upgrades und aktiviert automatische Sicherheitsupdates.', + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; + } + + /** + * The exact shell command(s) an action will run — shown to the operator + * before applying. Pure string-building, no remote calls. + */ + public function preview(Server $server, string $action): string + { + return match ($action) { + 'ssh_root' => $this->sshDropInPreview('PermitRootLogin no'), + 'ssh_password' => $this->sshDropInPreview('PasswordAuthentication no'), + 'fail2ban' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban'."\n" + .'systemctl enable --now fail2ban', + 'unattended' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'."\n" + .'systemctl enable --now unattended-upgrades'."\n" + .'dpkg-reconfigure -f noninteractive unattended-upgrades', + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; + } + + /** + * Apply an action over SSH (as root). Dispatcher over the action keys. + * + * @return array{ok: bool, output: string, preview: string} + */ + public function apply(Server $server, string $action): array + { + return match ($action) { + 'ssh_root' => $this->applySshRoot($server), + 'ssh_password' => $this->applySshPassword($server), + 'fail2ban' => $this->applyFail2ban($server), + 'unattended' => $this->applyUnattended($server), + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; + } + + /** Disable direct SSH root login via a drop-in, then reload sshd. */ + private function applySshRoot(Server $server): array + { + $preview = $this->preview($server, 'ssh_root'); + $res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no')); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** + * GUARD: refuse to disable password auth unless >=1 authorized SSH key is + * present — otherwise the operator could be locked out (Aussperrgefahr). + */ + private function applySshPassword(Server $server): array + { + $preview = $this->preview($server, 'ssh_password'); + + // GUARD 1: if Clusev itself logs in with a PASSWORD, disabling password + // auth would cut Clusev's own access (its credential is not a key) — refuse. + if ($server->credential?->auth_type === 'password') { + return [ + 'ok' => false, + 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.', + 'preview' => $preview, + ]; + } + + // GUARD 2: there must be at least one authorized key on the box. + if (! $this->hasAuthorizedKey($server)) { + return [ + 'ok' => false, + 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).', + 'preview' => $preview, + ]; + } + + $res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no')); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** Install + enable fail2ban. */ + private function applyFail2ban(Server $server): array + { + $preview = $this->preview($server, 'fail2ban'); + $cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban'; + $res = $this->fleet->runPrivileged($server, $cmd); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** Install + enable unattended-upgrades. */ + private function applyUnattended(Server $server): array + { + $preview = $this->preview($server, 'unattended'); + $cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades' + .' && systemctl enable --now unattended-upgrades' + .' && dpkg-reconfigure -f noninteractive unattended-upgrades'; + $res = $this->fleet->runPrivileged($server, $cmd); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + } + + /** + * True if the SSH user has at least one authorized key. Tries the structured + * FleetService::sshKeys() first, then falls back to a raw grep so the guard + * never wrongly reports "no key" on a parsing hiccup. + */ + private function hasAuthorizedKey(Server $server): bool + { + try { + if (count($this->fleet->sshKeys($server)) > 0) { + return true; + } + } catch (\Throwable) { + // fall through to the raw check + } + + $res = $this->fleet->runPlain( + $server, + 'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0' + ); + + return $res['ok'] && (int) trim($res['output']) > 0; + } + + /** Human-readable preview of writing a directive into the Clusev drop-in. */ + private function sshDropInPreview(string $directive): string + { + return 'mkdir -p /etc/ssh/sshd_config.d'."\n" + ."echo '{$directive}' > ".self::SSHD_DROPIN."\n" + .'systemctl reload ssh || systemctl reload sshd'; + } + + /** + * The shell run to install one sshd directive: ensure the drop-in dir, write + * the directive, then reload whichever ssh unit exists. Appends if the + * drop-in already holds other Clusev directives (keeps both lines). + */ + private function sshDropInScript(string $directive): string + { + $key = strtok($directive, ' '); + + return 'mkdir -p /etc/ssh/sshd_config.d' + .' && touch '.self::SSHD_DROPIN + // drop any prior copy of this directive, then append the new value + .' && grep -viE "^[[:space:]]*'.$key.'[[:space:]]" '.self::SSHD_DROPIN.' > '.self::SSHD_DROPIN.'.tmp 2>/dev/null; ' + .'mv '.self::SSHD_DROPIN.'.tmp '.self::SSHD_DROPIN.' 2>/dev/null; ' + .'echo "'.$directive.'" >> '.self::SSHD_DROPIN + .' && (systemctl reload ssh || systemctl reload sshd)'; + } +} diff --git a/app/Support/Ssh/CredentialVault.php b/app/Support/Ssh/CredentialVault.php index 9f62f14..74a9dd5 100644 --- a/app/Support/Ssh/CredentialVault.php +++ b/app/Support/Ssh/CredentialVault.php @@ -25,6 +25,10 @@ class CredentialVault throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt."); } + if ($cred->disabled_at !== null) { + throw new RuntimeException("SSH-Zugang für {$server->name} ist gesperrt."); + } + $secret = $cred->auth_type === 'key' ? PublicKeyLoader::load($cred->secret, $cred->passphrase ?: false) : $cred->secret; // password auth diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2848f..ec84274 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [ + \App\Http\Middleware\SecurityHeaders::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_06_13_010000_add_name_and_status_to_ssh_credentials.php b/database/migrations/2026_06_13_010000_add_name_and_status_to_ssh_credentials.php new file mode 100644 index 0000000..593cd5a --- /dev/null +++ b/database/migrations/2026_06_13_010000_add_name_and_status_to_ssh_credentials.php @@ -0,0 +1,26 @@ +string('name')->nullable()->after('server_id'); + // Soft "sperren": a disabled credential is kept but refused by the vault. + $table->timestamp('disabled_at')->nullable()->after('passphrase'); + $table->timestamp('last_used_at')->nullable()->after('disabled_at'); + }); + } + + public function down(): void + { + Schema::table('ssh_credentials', function (Blueprint $table) { + $table->dropColumn(['name', 'disabled_at', 'last_used_at']); + }); + } +}; diff --git a/database/migrations/2026_06_13_010001_create_settings_table.php b/database/migrations/2026_06_13_010001_create_settings_table.php new file mode 100644 index 0000000..873a9db --- /dev/null +++ b/database/migrations/2026_06_13_010001_create_settings_table.php @@ -0,0 +1,24 @@ +string('key')->primary(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/install.sh b/install.sh index 4050125..fcd01a3 100755 --- a/install.sh +++ b/install.sh @@ -111,6 +111,12 @@ force_kv SITE_ADDRESS "$SITE_ADDRESS" force_kv REVERB_HOST "$REVERB_HOST" force_kv REVERB_PORT "$REVERB_PORT" force_kv REVERB_SCHEME "$REVERB_SCHEME" +# Session hardening (control-plane self-security): strict same-site + end on +# browser close everywhere; secure cookie ONLY behind TLS (else the cookie is +# never returned over plain HTTP and login silently breaks). +force_kv SESSION_SAME_SITE strict +force_kv SESSION_EXPIRE_ON_CLOSE true +if [ -n "$DOMAIN" ]; then force_kv SESSION_SECURE_COOKIE true; else force_kv SESSION_SECURE_COOKIE false; fi force_kv HOST_UID "$(id -u)" force_kv HOST_GID "$(id -g)" info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet" diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 7c4c8a5..6f97f74 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -32,6 +32,7 @@

Konto

Einstellungen + System Version diff --git a/resources/views/livewire/modals/edit-credential.blade.php b/resources/views/livewire/modals/edit-credential.blade.php index a437d39..0ba9d2b 100644 --- a/resources/views/livewire/modals/edit-credential.blade.php +++ b/resources/views/livewire/modals/edit-credential.blade.php @@ -10,6 +10,11 @@
+
+ + +
+
+ + + +
+

{{ $heading ?: 'Härtung anwenden' }}

+ @if ($description) +

{{ $description }}

+ @endif +
+
+ + @if ($error) +
+ +

{{ $error }}

+
+ @else +
+

Befehle (als root)

+
{{ $preview }}
+
+ + @if ($done) +
$ok, + 'border-offline/25 bg-offline/10' => ! $ok, + ])> +

$ok, + 'text-offline' => ! $ok, + ])> + + {{ $ok ? 'Angewendet' : 'Fehlgeschlagen' }} +

+ @if ($output !== '') +
{{ $output }}
+ @endif +
+ @endif + @endif + +
+ @if ($done) + Schließen + @else + Abbrechen + @unless ($error) + + + + Anwenden + + @endunless + @endif +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 51cfe8d..6ece49c 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -64,9 +64,6 @@
- - Zugang - Dateien @@ -80,6 +77,63 @@
@endif + {{-- SSH-Zugang (Credential) --}} + @php + $cred = $server->credential; + $credStatus = $cred ? ($cred->disabled ? 'offline' : 'online') : null; + $credStatusLabel = $cred ? ($cred->disabled ? 'Gesperrt' : 'Aktiv') : null; + $credAuthLabel = $cred ? ($cred->auth_type === 'key' ? 'SSH-Key' : 'Passwort') : null; + @endphp + + @if ($cred) +
+ ! $cred->disabled, + 'border-offline/30 bg-offline/10 text-offline' => $cred->disabled, + ])> + + +
+
+

{{ $cred->name ?: '—' }}

+ {{ $credStatusLabel }} + {{ $credAuthLabel }} +
+

+ {{ $cred->username }}@{{ $server->ip }} +

+
+
+ + Bearbeiten + + + + + {{ $cred->disabled ? 'Entsperren' : 'Sperren' }} + + + Löschen + +
+
+ @else +
+ + + +
+

Kein Zugang hinterlegt

+

Ohne SSH-Zugang ist keine Live-Steuerung möglich.

+
+ + Zugang hinterlegen + +
+ @endif +
+ {{-- Vitals: live gauges, full width, dense --}}
@foreach ($vitals as $v) @@ -111,7 +165,21 @@
+ @php + // Map each checklist label to its hardening-action key. + $hardenKey = function (string $label): ?string { + return match (true) { + str_contains($label, 'Root-Login') => 'ssh_root', + str_contains($label, 'Passwort-Login') => 'ssh_password', + str_contains($label, 'fail2ban') => 'fail2ban', + str_contains($label, 'Firewall') => 'firewall', + str_contains($label, 'Updates') => 'unattended', + default => null, + }; + }; + @endphp @foreach ($hardening as $check) + @php $action = $hardenKey($check['label']); @endphp
$check['status'] === 'online', @@ -122,9 +190,16 @@

{{ $check['label'] }}

{{ $check['detail'] }}

- - {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} - + @if ($check['status'] !== 'online' && $action) + + Anwenden + + @else + + {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} + + @endif
@endforeach
diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php new file mode 100644 index 0000000..6de03a6 --- /dev/null +++ b/resources/views/livewire/system/index.blade.php @@ -0,0 +1,119 @@ +@php + $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp + +
+ {{-- Header --}} +
+ + + +
+

System

+

Domain, TLS & Release-Kanal

+

Zugriffsadresse des Panels und Update-Quelle

+
+ $hasTls, + 'border-warning/30 text-warning' => ! $hasTls, + ])> + {{ $hasTls ? 'HTTPS aktiv' : 'Klartext-HTTP' }} + +
+ + {{-- Domain & TLS --}} + + {{-- Current TLS state --}} + @if ($hasTls) +
+ +
+

HTTPS via Let's Encrypt (Caddy)

+

Caddy stellt automatisch ein Zertifikat für {{ $domain }} aus und erneuert es.

+
+
+ @else +
+ +
+

Bare-IP-Modus: Klartext-HTTP

+

Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Für den Produktivbetrieb eine Domain setzen.

+
+
+ @endif + +
+
+
+ + + @error('domain')

{{ $message }}

@enderror +

Leer lassen für Zugriff per IP über HTTP.

+
+
+ + + @error('email')

{{ $message }}

@enderror +

Für Zertifikatshinweise von Let's Encrypt.

+
+
+
+ + + Speichern + +
+
+ + {{-- Generated Caddy site config --}} +
+
+ +

Erzeugte Caddy-Konfiguration

+
+
{{ $caddyConfig }}
+
+ +
+

Caddy läuft als eigener Container und muss neu laden, um die Konfiguration zu übernehmen.

+

Die Datei wurde nach storage/app/caddy/clusev.caddy geschrieben. Im Caddy-Container ausführen:

+
{{ $reloadHint }}
+
+
+
+
+ + {{-- Release-Kanal --}} + +
+ {{-- Segmented control --}} +
+ @foreach ($channels as $key => $desc) + + @endforeach +
+ + {{-- Per-channel description --}} +
+ +
+

Kanal: {{ $channel }}

+

{{ $channels[$channel] ?? '' }}

+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 68e918e..a6d8160 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Livewire\Files; use App\Livewire\Servers; use App\Livewire\Services; use App\Livewire\Settings; +use App\Livewire\System; use App\Livewire\Versions; use Illuminate\Support\Facades\Auth as AuthFacade; use Illuminate\Support\Facades\Route; @@ -43,5 +44,6 @@ Route::middleware('auth')->group(function () { Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/versions', Versions\Index::class)->name('versions'); + Route::get('/system', System\Index::class)->name('system'); }); });