diff --git a/app/Livewire/Modals/CreateServer.php b/app/Livewire/Modals/CreateServer.php new file mode 100644 index 0000000..813921f --- /dev/null +++ b/app/Livewire/Modals/CreateServer.php @@ -0,0 +1,128 @@ +> + */ + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + // Accept either a real IP (validated by PHP's filter) or a valid DNS hostname. + 'ip' => ['required', 'string', 'max:255', function ($attribute, $value, $fail) { + $value = (string) $value; + $isIp = filter_var($value, FILTER_VALIDATE_IP) !== false; + // A digits-and-dots string is an IPv4 attempt — it must be a VALID IP, + // not a numeric "hostname" (so 999.999.999.999 is rejected). + $numericDotted = preg_match('/^[0-9.]+$/', $value) === 1; + $isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1; + if (! $isIp && ! $isHost) { + $fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.'); + } + }], + 'sshPort' => ['required', 'integer', 'min:1', 'max:65535'], + 'username' => ['required', 'string', 'max:120'], + 'authType' => ['required', Rule::in(['password', 'key'])], + 'secret' => ['required', 'string'], + 'passphrase' => ['nullable', 'string'], + 'credentialName' => ['nullable', 'string', 'max:120'], + ]; + } + + /** + * @return array + */ + protected function validationAttributes(): array + { + return [ + 'name' => 'Name', + 'ip' => 'IP/Host', + 'sshPort' => 'SSH-Port', + 'username' => 'Benutzer', + 'authType' => 'Authentifizierung', + 'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort', + 'credentialName' => 'Zugangs-Name', + ]; + } + + public function save(): void + { + $data = $this->validate(); + + // Atomic: a failure creating the credential or audit must not leave a + // server row without its required SSH credential. + $server = DB::transaction(function () use ($data) { + $server = Server::create([ + 'name' => trim($data['name']), + 'ip' => trim($data['ip']), + 'ssh_port' => (int) $data['sshPort'], + 'status' => 'offline', + ]); + + $server->credential()->create([ + 'name' => trim($this->credentialName) !== '' ? trim($this->credentialName) : null, + 'username' => trim($data['username']), + 'auth_type' => $data['authType'] === 'key' ? 'key' : 'password', + 'secret' => $this->secret, + 'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null, + ]); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'server.create', + 'target' => $server->name.' · '.$server->ip, + 'ip' => request()->ip(), + ]); + + return $server; + }); + + $this->dispatch('serverCreated'); + $this->dispatch('notify', message: 'Server hinzugefügt.'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.create-server'); + } +} diff --git a/app/Livewire/Modals/Fail2banConfig.php b/app/Livewire/Modals/Fail2banConfig.php new file mode 100644 index 0000000..7a41ba3 --- /dev/null +++ b/app/Livewire/Modals/Fail2banConfig.php @@ -0,0 +1,139 @@ +serverId = $serverId; + + $server = Server::find($serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + $this->serverName = $server->name; + + try { + $current = $maintenance->readFail2ban($server); + $this->bantime = $current['bantime']; + $this->maxretry = $current['maxretry']; + $this->findtime = $current['findtime']; + $this->loaded = true; + } catch (Throwable $e) { + $this->error = $e->getMessage(); + } + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function save(MaintenanceService $maintenance): void + { + // Never overwrite the remote policy with defaults the operator never saw — + // saving is only allowed once the current values were read successfully. + if (! $this->loaded) { + $this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.'; + + return; + } + + $this->error = null; + + // fail2ban duration grammar: a number (seconds) optionally with a unit + // (s/m/h/d/w/mo/y), space-separated composites, or -1 for a permanent ban. + $duration = ['required', 'string', 'max:40', 'regex:/^(?:-1|\d+(?:\.\d+)?\s*(?:s|m|h|d|w|mo|y)?(?:\s+\d+(?:\.\d+)?\s*(?:s|m|h|d|w|mo|y)?)*)$/i']; + + $validated = $this->validate([ + 'bantime' => $duration, + 'maxretry' => ['required', 'integer', 'min:1', 'max:100'], + 'findtime' => $duration, + ], [ + 'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).', + 'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).', + ], [ + 'bantime' => 'Sperrdauer', + 'maxretry' => 'Max. Fehlversuche', + 'findtime' => 'Zeitfenster', + ]); + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + // Values are already validated/clamped to the allowed ranges above. + $result = $maintenance->writeFail2ban( + $server, + (string) $validated['bantime'], + (int) $validated['maxretry'], + (string) $validated['findtime'], + ); + } catch (Throwable $e) { + $this->error = $e->getMessage(); + + return; + } + + if (! $result['ok']) { + $this->error = 'Speichern fehlgeschlagen. '.$result['output']; + + return; + } + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'fail2ban.configure', + 'target' => $server->name.' · bantime '.$validated['bantime'].', maxretry '.$validated['maxretry'], + 'ip' => request()->ip(), + ]); + + $this->dispatch('hardeningApplied'); + $this->dispatch('notify', message: 'fail2ban konfiguriert.'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.fail2ban-config'); + } +} diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index d42bfc8..076d0cd 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -30,8 +30,6 @@ class HardeningAction extends ModalComponent public string $description = ''; - public string $preview = ''; - public bool $done = false; public bool $ok = false; @@ -57,7 +55,6 @@ class HardeningAction extends ModalComponent $hardening = app(HardeningService::class); $this->heading = $hardening->title($action, $enable); $this->description = $hardening->description($action, $enable); - $this->preview = $hardening->preview($server, $action, $enable); } catch (Throwable $e) { $this->error = $e->getMessage(); } @@ -93,7 +90,8 @@ class HardeningAction extends ModalComponent $this->done = true; $this->ok = $result['ok']; - $this->output = $result['output'] !== '' ? $result['output'] : ($result['ok'] ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.'); + // Clean result only — no raw command output on success; a short reason on failure. + $this->output = $this->ok ? '' : \Illuminate\Support\Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200); AuditEvent::create([ 'user_id' => Auth::id(), diff --git a/app/Livewire/Modals/SystemUpdate.php b/app/Livewire/Modals/SystemUpdate.php new file mode 100644 index 0000000..1afdf25 --- /dev/null +++ b/app/Livewire/Modals/SystemUpdate.php @@ -0,0 +1,115 @@ +serverId = $serverId; + + $server = Server::find($serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + $this->serverName = $server->name; + + try { + $this->hasApt = $maintenance->hasApt($server); + $this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null; + } catch (Throwable $e) { + $this->error = $e->getMessage(); + } + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function upgrade(MaintenanceService $maintenance): void + { + if ($this->error !== null || $this->done || ! $this->hasApt) { + return; + } + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + $result = $maintenance->aptUpgrade($server); + } catch (Throwable $e) { + $this->done = true; + $this->ok = false; + $this->output = $e->getMessage(); + $this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.'); + + return; + } + + $this->done = true; + $this->ok = $result['ok']; + // On success we surface a clean German line, not the raw apt log; on failure + // the trimmed error tail helps the operator diagnose. + $this->output = $this->ok ? '' : $result['output']; + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'system.apt_upgrade', + 'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'), + 'ip' => request()->ip(), + ]); + + if ($this->ok) { + $this->pending = 0; + $this->dispatch('notify', message: 'System aktualisiert.'); + } else { + $this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.'); + } + } + + public function render() + { + return view('livewire.modals.system-update'); + } +} diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php index ef8574e..f4d824c 100644 --- a/app/Livewire/Servers/Index.php +++ b/app/Livewire/Servers/Index.php @@ -5,6 +5,7 @@ namespace App\Livewire\Servers; use App\Models\Server; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -15,6 +16,13 @@ class Index extends Component /** Live search over name / IP. */ public string $search = ''; + /** A freshly added server should appear without a manual reload (render() re-queries). */ + #[On('serverCreated')] + public function refreshServers(): void + { + // + } + public function render(): View { $term = trim($this->search); diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 753219a..8c6fb38 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -125,7 +125,6 @@ class Index extends Component 'hasTls' => $this->domain !== '', 'caddyConfig' => $deployment->renderCaddyConfig(), 'reloadHint' => $deployment->reloadHint(), - 'whyNotEnv' => $deployment->whyNotEnv(), 'channels' => self::CHANNELS, ]); } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index d47e04d..1e03334 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -22,8 +22,7 @@ use App\Models\Setting; * .env IS NEVER WRITTEN. This service only persists Settings (DB) and writes a * standalone Caddy site block to storage/app/caddy/clusev.caddy. It does not read, * edit, or rewrite the application's .env in any way — the panel domain is a database - * value, so a change to it never mutates .env. See whyNotEnv() for the operator-facing - * explanation surfaced in the dashboard. + * value, so a change to it never mutates .env. */ class DeploymentService { @@ -137,16 +136,4 @@ class DeploymentService { return 'caddy reload --config /config/'.self::CONFIG_FILENAME; } - - /** - * German operator explanation: the panel domain lives in the DATABASE (Settings), - * NOT in .env — and the app never overwrites .env. Surfaced in the dashboard to - * clear up the worry that every domain change rewrites .env. Changes take effect - * after a Caddy reload (the proxy runs in its own container). - */ - public function whyNotEnv(): string - { - return 'Die Domain liegt in der Datenbank, nicht in der .env — die .env wird nie ' - .'überschrieben. Änderungen greifen nach einem Caddy-Reload (`caddy reload`).'; - } } diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index 8eedcd7..66582ab 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -42,65 +42,42 @@ class FirewallService 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. + * enable a firewall that would drop the current SSH session. Installs ufw if missing. * - * @return array{ok: bool, output: string, preview: string} + * @return array{ok: bool, output: string} */ public function enable(Server $server): array { - $port = $this->sshPort($server); - $preview = $this->enableScript($port); // long timeout: may apt-install ufw first. - $res = $this->fleet->runPrivileged($server, $this->enableScript($port), 600); + $res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return ['ok' => $res['ok'], 'output' => $res['output']]; } - /** Turn the firewall off (ufw disable). */ + /** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */ public function disable(Server $server): array { $res = $this->fleet->runPrivileged($server, 'ufw disable'); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => 'ufw disable']; + return ['ok' => $res['ok'], 'output' => $res['output']]; } - /** - * Allow a TCP port through the firewall. - * - * @return array{ok: bool, output: string, preview: string} - */ + /** Allow a TCP port through the firewall. @return array{ok: bool, output: string} */ public function allow(Server $server, int $port): array { - $port = $this->clampPort($port); - $preview = "ufw allow {$port}/tcp"; - $res = $this->fleet->runPrivileged($server, $preview); + $res = $this->fleet->runPrivileged($server, 'ufw allow '.$this->clampPort($port).'/tcp'); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return ['ok' => $res['ok'], 'output' => $res['output']]; } - /** - * Deny a TCP port through the firewall. - * - * @return array{ok: bool, output: string, preview: string} - */ + /** Deny a TCP port through the firewall. @return array{ok: bool, output: string} */ public function deny(Server $server, int $port): array { - $port = $this->clampPort($port); - $preview = "ufw deny {$port}/tcp"; - $res = $this->fleet->runPrivileged($server, $preview); + $res = $this->fleet->runPrivileged($server, 'ufw deny '.$this->clampPort($port).'/tcp'); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return ['ok' => $res['ok'], 'output' => $res['output']]; } /** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */ diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 58f39c0..2051023 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -132,34 +132,22 @@ class HardeningService }; } - /** The EXACT root command(s) the toggle will run — identical to what apply() executes. */ - public function preview(Server $server, string $action, bool $enable): string - { - if ($action === 'firewall') { - return $enable ? $this->firewall->enablePreview($server) : 'ufw disable'; - } - - return $this->commandFor($action, $enable); - } - - /** @return array{ok: bool, output: string, preview: string} */ + /** @return array{ok: bool, output: string} */ public function apply(Server $server, string $action, bool $enable): array { - $preview = $this->preview($server, $action, $enable); - if ($action === 'firewall') { $res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return ['ok' => $res['ok'], 'output' => $res['output']]; } // Lock-out guard: never disable password auth without a usable key path. if ($action === 'ssh_password' && ! $enable) { if ($server->credential?->auth_type === 'password') { - return ['ok' => false, 'preview' => $preview, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.']; + 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.']; } if (! $this->hasAuthorizedKey($server)) { - return ['ok' => false, 'preview' => $preview, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).']; + return ['ok' => false, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).']; } } @@ -167,7 +155,7 @@ class HardeningService $timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60; $res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout); - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return ['ok' => $res['ok'], 'output' => $res['output']]; } /** Single source of truth for the shell command of a (non-firewall) toggle. */ diff --git a/app/Services/MaintenanceService.php b/app/Services/MaintenanceService.php new file mode 100644 index 0000000..d8eeb10 --- /dev/null +++ b/app/Services/MaintenanceService.php @@ -0,0 +1,181 @@ +fleet->runPlain($server, 'command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0'); + + return $res['ok'] && trim($res['output']) === '1'; + } + + /** + * Number of pending package upgrades via a dry-run simulation (no root needed), + * or NULL when the simulation could not run (apt absent / errored) — so the caller + * can show "unbekannt" rather than a misleading "0 / up to date". + */ + public function pendingUpdates(Server $server): ?int + { + $res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null'); + if (! $res['ok']) { + return null; + } + + return (int) preg_match_all('/^Inst /m', $res['output']); + } + + /** + * apt-get update && upgrade as root. Long-running, so a 900s timeout. The + * returned output is trimmed to the last ~400 chars for a compact result panel. + * + * @return array{ok: bool, output: string} + */ + public function aptUpgrade(Server $server): array + { + $res = $this->fleet->runPrivileged( + $server, + 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade', + 900, + ); + + return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)]; + } + + /** + * Read the Clusev-managed fail2ban [DEFAULT] tuning from our OWN drop-in only — + * we never read the operator's jail.local or per-jail sections, so a jail's + * `maxretry` can't masquerade as the global default. Defaults when unset. + * + * @return array{bantime: int, maxretry: int, findtime: int} + */ + public function readFail2ban(Server $server): array + { + // Read the [DEFAULT] section across all fail2ban files in fail2ban's own + // precedence order (last value wins). A CLUSEV_RESET marker between files + // stops a section bleeding across file boundaries; only [DEFAULT] keys count + // (a jail's maxretry must not masquerade as the global default). + // A leading sentinel proves the read actually RAN (sudo/SSH ok). We key success + // off the sentinel — NOT the loop's exit code — so a final unmatched glob (a + // benign "no drop-ins" case) does not look like a failure, while a genuine + // sudo/permission/SSH error (no sentinel echoed) IS propagated as a throw and can + // never be downgraded to defaults the operator might then overwrite. + $cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf ' + .'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do ' + .'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null'; + + $res = $this->fleet->runPrivileged($server, $cmd); + if (! str_contains($res['output'], 'CLUSEV_READ_OK')) { + throw new \RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.'); + } + + return $this->parseFail2banDefaults($res['output']); + } + + /** + * Section-aware parse of the effective [DEFAULT] tuning (last value wins), + * converting fail2ban time units (s/m/h/d/w) to seconds. + * + * @return array{bantime: int, maxretry: int, findtime: int} + */ + private function parseFail2banDefaults(string $body): array + { + // bantime/findtime are kept VERBATIM — fail2ban's native duration grammar + // (600, 10m, 1h 30m, -1 for permanent, …) is preserved, never lossily converted + // to seconds. maxretry is a plain integer. + $vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m']; + $inDefault = false; + + foreach (preg_split('/\R/', $body) ?: [] as $line) { + $t = trim($line); + if ($t === '' || $t[0] === '#' || $t[0] === ';') { + continue; + } + if (preg_match('/^\[([^\]]+)\]/', $t, $m)) { + $inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0; + + continue; + } + if (! $inDefault) { + continue; + } + if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) { + $vals['bantime'] = $m[1]; + } elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) { + $vals['findtime'] = $m[1]; + } elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) { + $vals['maxretry'] = (int) $m[1]; + } + } + + return $vals; + } + + /** + * Write the Clusev [DEFAULT] block to our OWN jail.d drop-in (overwriting only + * that file — the operator's jail.local and jail definitions are untouched), + * then reload (fallback restart) fail2ban. The integers MUST already be clamped + * by the caller; cast here as a last line of defence. + * + * @return array{ok: bool, output: string} + */ + public function writeFail2ban(Server $server, string $bantime, int $maxretry, string $findtime): array + { + $maxretry = (int) $maxretry; + // Durations stay in fail2ban's native grammar (validated by the caller); collapse + // any stray whitespace/newlines so the written ini line stays well-formed. + $bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? ''); + $findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? ''); + + // Build the config with base64 so it never touches the shell unquoted; the + // whole command is itself base64-wrapped again by runPrivileged. + $content = "# Managed by Clusev — do not edit by hand.\n" + ."[DEFAULT]\n" + ."bantime = {$bantime}\n" + ."findtime = {$findtime}\n" + ."maxretry = {$maxretry}\n"; + + $b64 = base64_encode($content); + + // Write the drop-in; reload ONLY if fail2ban is already running (never start an + // inactive service from the settings form). A reload failure while active IS + // propagated (the `if` returns reload's exit code); inactive succeeds silently. + $cmd = 'mkdir -p /etc/fail2ban/jail.d' + .' && printf %s '.$b64.' | base64 -d > '.self::FAIL2BAN_DROPIN + .' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban; fi'; + + $res = $this->fleet->runPrivileged($server, $cmd, 120); + + return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)]; + } + + /** Keep only the last $max characters of a (multi-line) output. */ + private function tail(string $out, int $max): string + { + $out = trim($out); + if (mb_strlen($out) <= $max) { + return $out; + } + + return '…'.mb_substr($out, -$max); + } +} diff --git a/resources/views/livewire/modals/create-server.blade.php b/resources/views/livewire/modals/create-server.blade.php new file mode 100644 index 0000000..6df48f3 --- /dev/null +++ b/resources/views/livewire/modals/create-server.blade.php @@ -0,0 +1,87 @@ +
+
+ + + +
+

Server hinzufügen

+

Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.

+
+
+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ + + @error('ip')

{{ $message }}

@enderror +
+
+ + + @error('sshPort')

{{ $message }}

@enderror +
+
+ +
+ + + @error('username')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ + @if ($authType === 'key') + + @else + + @endif + @error('secret')

{{ $message }}

@enderror +
+ + @if ($authType === 'key') +
+ + +
+ @endif + +
+ + + @error('credentialName')

{{ $message }}

@enderror +
+
+ +
+ Abbrechen + + + + Hinzufügen + +
+
diff --git a/resources/views/livewire/modals/fail2ban-config.blade.php b/resources/views/livewire/modals/fail2ban-config.blade.php new file mode 100644 index 0000000..6976cab --- /dev/null +++ b/resources/views/livewire/modals/fail2ban-config.blade.php @@ -0,0 +1,57 @@ +
+
+ + + +
+

fail2ban konfigurieren

+

+ Legt fest, wann fail2ban auf {{ $serverName ?: 'diesem Server' }} eine IP sperrt und wie lange. + Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre. +

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

{{ $error }}

+
+ @endif + + @if ($loaded) +
+
+ + + @error('bantime')

{{ $message }}

@enderror +
+ +
+ + + @error('maxretry')

{{ $message }}

@enderror +
+ +
+ + + @error('findtime')

{{ $message }}

@enderror +
+
+ @endif + +
+ {{ $loaded ? 'Abbrechen' : 'Schließen' }} + @if ($loaded) + + + + Speichern + + @endif +
+
diff --git a/resources/views/livewire/modals/hardening-action.blade.php b/resources/views/livewire/modals/hardening-action.blade.php index 5b4af84..4ed0971 100644 --- a/resources/views/livewire/modals/hardening-action.blade.php +++ b/resources/views/livewire/modals/hardening-action.blade.php @@ -16,31 +16,20 @@

{{ $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 }}
+ @elseif ($done) +
$ok, + 'border-offline/25 bg-offline/10' => ! $ok, + ])> + $ok, 'text-offline' => ! $ok]) /> +
+

$ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}

+ @if (! $ok && $output !== '') +

{{ $output }}

@endif
- @endif +
@endif
diff --git a/resources/views/livewire/modals/system-update.blade.php b/resources/views/livewire/modals/system-update.blade.php new file mode 100644 index 0000000..8678666 --- /dev/null +++ b/resources/views/livewire/modals/system-update.blade.php @@ -0,0 +1,90 @@ +
+
+ + + +
+

System-Updates

+

+ Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die + Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt. +

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

{{ $error }}

+
+ @elseif (! $hasApt) +
+ +

Nur für Debian/Ubuntu (apt) verfügbar.

+
+ @else +
+ $pending !== null && $pending > 0, + 'border-online/25 bg-online/10 text-online' => $pending === 0, + 'border-line bg-raised text-ink-3' => $pending === null, + ])> + + +
+

+ @if ($pending === null) + Update-Anzahl unbekannt + @elseif ($pending === 1) + 1 Paket-Update verfügbar + @else + {{ $pending }} Paket-Updates verfügbar + @endif +

+

+ @if ($pending === null) + Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich. + @else + {{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }} + @endif +

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

$ok, + 'text-offline' => ! $ok, + ])> + + {{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }} +

+ @if (! $ok && $output !== '') +
{{ $output }}
+ @endif +
+ @endif + @endif + +
+ @if ($done) + Schließen + @else + Abbrechen + @if (! $error && $hasApt) + + + + Jetzt aktualisieren + + @endif + @endif +
+
diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php index 2e7a875..d26430e 100644 --- a/resources/views/livewire/servers/index.blade.php +++ b/resources/views/livewire/servers/index.blade.php @@ -9,7 +9,13 @@

Flotte

Server

- {{ $online }} / {{ $total }} online +
+ + + Server hinzufügen + + {{ $online }} / {{ $total }} online +
{{-- KPI grid: 1 → 2 → 4 --}} diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index d7d38d6..8b16e63 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -64,6 +64,9 @@
+ + System-Updates + Dateien @@ -186,6 +189,12 @@

{{ $check['detail'] }}

+ @if ($check['key'] === 'fail2ban') + + + + @endif {{-- Toggle: secondary to make secure, quiet ghost to loosen. --}} diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index aa77e47..6de03a6 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -26,16 +26,6 @@ {{-- Domain & TLS --}} - {{-- DB-not-.env clarification: the panel domain is a database value; .env is never rewritten. --}} -
- -
-

Die Domain wird in der Datenbank gespeichert, nicht in der .env.

-

{{ $whyNotEnv }}

-

Die .env wird vom Panel nie überschrieben. Laravel übernimmt die neue Adresse sofort (Laufzeit-Override von app.url); die Auslieferung greift nach dem Caddy-Reload unten.

-
-
- {{-- Current TLS state --}} @if ($hasTls)