diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index b8abf90..d42bfc8 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -4,21 +4,18 @@ namespace App\Livewire\Modals; use App\Models\AuditEvent; use App\Models\Server; -use App\Services\FirewallService; use App\Services\HardeningService; use Illuminate\Support\Facades\Auth; use LivewireUI\Modal\ModalComponent; use Throwable; /** - * Preview + confirm modal for a single server-hardening action (R5). + * Preview + confirm modal for one server-hardening TOGGLE (R5). * - * mount() loads a human title + the EXACT shell commands (from the service's - * preview) so the operator sees what will run BEFORE applying. `apply()` runs - * the action over SSH, AUDITs (`harden.`), notifies, asks the page to - * reload the snapshot, and — on success — closes. - * - * Action keys: ssh_root, ssh_password, fail2ban, unattended, firewall. + * `$enable` is the desired feature state — true turns the feature on, false off. + * mount() loads a direction-aware title + the exact root commands (preview) so the + * operator sees what will run BEFORE applying. apply() runs it, AUDITs, notifies, + * asks the page to reload its hardening state, and shows the result. */ class HardeningAction extends ModalComponent { @@ -26,8 +23,8 @@ class HardeningAction extends ModalComponent public string $action; - /** Optional TCP port (reserved for future allow/deny firewall actions). */ - public ?int $port = null; + /** Desired feature state: true = aktivieren, false = deaktivieren. */ + public bool $enable; public string $heading = ''; @@ -35,21 +32,19 @@ class HardeningAction extends ModalComponent 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 + public function mount(int $serverId, string $action, bool $enable): void { $this->serverId = $serverId; $this->action = $action; - $this->port = $port; + $this->enable = $enable; $server = Server::find($serverId); if (! $server) { @@ -59,16 +54,10 @@ class HardeningAction extends ModalComponent } 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); - } + $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,9 +82,7 @@ class HardeningAction extends ModalComponent } try { - $result = $this->action === 'firewall' - ? app(FirewallService::class)->enable($server) - : app(HardeningService::class)->apply($server, $this->action); + $result = app(HardeningService::class)->apply($server, $this->action, $this->enable); } catch (Throwable $e) { $this->done = true; $this->ok = false; @@ -112,7 +99,7 @@ class HardeningAction extends ModalComponent 'user_id' => Auth::id(), 'server_id' => $server->id, 'actor' => Auth::user()?->name ?? 'system', - 'action' => 'harden.'.$this->action, + 'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'), 'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'), 'ip' => request()->ip(), ]); diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 71b7170..1d3e680 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -5,6 +5,7 @@ namespace App\Livewire\Servers; use App\Models\AuditEvent; use App\Models\Server; use App\Services\FleetService; +use App\Services\HardeningService; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Attributes\On; @@ -73,8 +74,14 @@ class Show extends Component $this->volumes = $snap['volumes']; $this->interfaces = $snap['interfaces']; - $this->hardening = $snap['hardening']; $this->sshKeys = $snap['sshKeys']; + + // Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks). + try { + $this->hardening = app(HardeningService::class)->state($this->server); + } catch (Throwable) { + $this->hardening = []; + } $this->loadAvg = (float) ($snap['metrics']['load'] ?? 0); $this->connected = true; } catch (Throwable) { diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index cd82be9..8eedcd7 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -61,11 +61,20 @@ class FirewallService { $port = $this->sshPort($server); $preview = $this->enableScript($port); - $res = $this->fleet->runPrivileged($server, $this->enableScript($port)); + // long timeout: may apt-install ufw first. + $res = $this->fleet->runPrivileged($server, $this->enableScript($port), 600); return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; } + /** Turn the firewall off (ufw disable). */ + public function disable(Server $server): array + { + $res = $this->fleet->runPrivileged($server, 'ufw disable'); + + return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => 'ufw disable']; + } + /** * Allow a TCP port through the firewall. * @@ -94,10 +103,11 @@ class FirewallService return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; } - /** Build the guarded enable script for a given sshd port. */ + /** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */ private function enableScript(int $port): string { - return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable"; + return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && ' + ."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable"; } /** diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index fa0d91f..1cf6120 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -241,8 +241,7 @@ class FleetService .'echo '.self::MARK.'addr===; ip -o -4 addr show; ' .'echo '.self::MARK.'link===; ip -o link show; ' .'echo '.self::MARK.'netdev===; cat /proc/net/dev; ' - .'echo '.self::MARK.'sshd===; grep -Ei "^[[:space:]]*(permitrootlogin|passwordauthentication)" /etc/ssh/sshd_config 2>/dev/null; ' - .'echo '.self::MARK.'active===; for u in fail2ban ufw nftables unattended-upgrades; do echo "$u $(systemctl is-active $u 2>/dev/null)"; done; ' + // hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state(). .'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null'; try { @@ -275,7 +274,6 @@ class FleetService ], 'volumes' => $volumes, 'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''), - 'hardening' => $this->parseHardening($s['sshd'] ?? '', $s['active'] ?? ''), 'sshKeys' => $this->parseKeys($s['keys'] ?? ''), ]; } @@ -670,26 +668,6 @@ class FleetService return $out; } - private function parseHardening(string $sshd, string $active): string|array - { - $root = preg_match('/permitrootlogin\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes'; - $pwauth = preg_match('/passwordauthentication\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes'; - $svc = []; - foreach ($this->lines($active) as $line) { - [$name, $state] = array_pad(preg_split('/\s+/', trim($line)), 2, ''); - $svc[$name] = $state; - } - $isActive = fn (string $u) => ($svc[$u] ?? '') === 'active'; - $firewall = $isActive('ufw') || $isActive('nftables'); - - return [ - ['label' => 'SSH-Root-Login deaktiviert', 'detail' => "PermitRootLogin {$root}", 'status' => $root === 'no' ? 'online' : 'offline'], - ['label' => 'SSH-Passwort-Login deaktiviert', 'detail' => "PasswordAuthentication {$pwauth}", 'status' => $pwauth === 'no' ? 'online' : 'warning'], - ['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · '.($svc['fail2ban'] ?? 'unbekannt'), 'status' => $isActive('fail2ban') ? 'online' : 'offline'], - ['label' => 'Firewall aktiv', 'detail' => $firewall ? 'ufw/nftables · aktiv' : 'ufw/nftables · inaktiv', 'status' => $firewall ? 'online' : 'offline'], - ['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · '.($svc['unattended-upgrades'] ?? 'unbekannt'), 'status' => $isActive('unattended-upgrades') ? 'online' : 'warning'], - ]; - } private function parseKeys(string $body): array { diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index cdff944..58f39c0 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -6,150 +6,190 @@ use App\Models\Server; use InvalidArgumentException; /** - * Applies the dashboard server-hardening checklist items over SSH (as root). + * Applies the dashboard server-hardening toggles over SSH (as root). Every item is + * BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying feature + * on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand. * - * Safety model: "guards + confirmation". Every action exposes a `preview()` - * of the exact shell commands the operator sees BEFORE applying, and - * `ssh_password` is GUARDED so the operator can never lock themselves out by - * disabling password auth without an authorized SSH key in place. - * - * Each apply returns array{ok: bool, output: string, preview: string}. + * preview() returns the EXACT command apply() runs (single source via commandFor()), + * so the operator always confirms precisely what executes. Disabling SSH password + * auth is GUARDED so the operator can never lock themselves out. */ class HardeningService { - /** Drop-in that holds every Clusev-managed sshd override. */ - private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf'; + // 00- so it sorts + wins before distro drop-ins like 50-cloud-init.conf (sshd uses the first value). + private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/00-clusev.conf'; - public function __construct(private FleetService $fleet) {} + // 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*). + private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev'; - /** Human title for an action key (shown in the modal heading). */ - public function title(string $action): string - { - return match ($action) { - 'ssh_root' => '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}"), - }; - } + public function __construct(private FleetService $fleet, private FirewallService $firewall) {} /** - * 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. + * Live state of every hardening item, read PRIVILEGED in one shot. SSH uses + * `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); + * UFW uses `ufw status`; unattended uses the apt periodic config. * - * @return array{ok: bool, output: string, preview: string} + * @return array */ - public function apply(Server $server, string $action): array + public function state(Server $server): array + { + $cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; ' + .'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; ' + .'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; ' + .'[ "$ufw_inst" = "1" ] && ufw status 2>/dev/null | grep -qi "Status: active" && ufw_act=active; ' + .'echo "ufw $ufw_inst $ufw_act"; ' + .'ui=$(dpkg -l unattended-upgrades 2>/dev/null | grep -c "^ii"); ua=inactive; ' + // apt-config dump = the EFFECTIVE periodic value across all apt.conf.d files (last wins). + .'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\' && ua=active; ' + .'echo "unattended-upgrades $ui $ua"'; + + $res = $this->fleet->runPrivileged($server, $cmd); + if (! $res['ok']) { + throw new \RuntimeException('Härtungs-Status konnte nicht gelesen werden.'); + } + + return $this->parseState($res['output']); + } + + /** @return array */ + private function parseState(string $out): array + { + $root = 'default'; + $pwauth = 'default'; + $pkg = []; + + foreach (preg_split('/\R/', $out) as $line) { + $line = trim($line); + if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) { + $root = strtolower($m[1]); + } elseif (preg_match('/^passwordauthentication\s+(\S+)/i', $line, $m)) { + $pwauth = strtolower($m[1]); + } else { + $p = preg_split('/\s+/', $line); + if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) { + $pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active']; + } + } + } + + $f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false]; + $ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false]; + $upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false]; + + $detail = fn (string $name, array $st): string => $name.' · ' + .(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv')); + // A package feature is only effectively ON when the package is installed AND active — + // stale config (e.g. apt periodic = 1 after removal) must not read as secure. + $on = fn (array $st): bool => $st['installed'] && $st['active']; + + // `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill. + return [ + ['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root, + 'featureOn' => $root !== 'no', 'secure' => $root === 'no'], + ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth, + 'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'], + ['key' => 'fail2ban', 'label' => 'fail2ban', 'detail' => $detail('fail2ban.service', $f2b), + 'featureOn' => $on($f2b), 'secure' => $on($f2b)], + ['key' => 'firewall', 'label' => 'Firewall (UFW)', 'detail' => $detail('ufw', $ufw), + 'featureOn' => $on($ufw), 'secure' => $on($ufw)], + ['key' => 'unattended', 'label' => 'Automatische Updates', 'detail' => $detail('unattended-upgrades', $upg), + 'featureOn' => $on($upg), 'secure' => $on($upg)], + ]; + } + + /** Modal title, reflecting the toggle direction. */ + public function title(string $action, bool $enable): string { return match ($action) { - 'ssh_root' => $this->applySshRoot($server), - 'ssh_password' => $this->applySshPassword($server), - 'fail2ban' => $this->applyFail2ban($server), - 'unattended' => $this->applyUnattended($server), + 'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren', + 'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren', + 'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren', + 'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren', + 'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren', 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 + public function description(string $action, bool $enable): string { - $preview = $this->preview($server, 'ssh_root'); - $res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no')); - - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return match ($action) { + 'ssh_root' => $enable + ? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.' + : 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.', + 'ssh_password' => $enable + ? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).' + : 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.', + 'fail2ban' => $enable + ? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.' + : 'Stoppt fail2ban und deaktiviert den Autostart.', + 'firewall' => $enable + ? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.' + : 'Deaktiviert die UFW-Firewall (ufw disable).', + 'unattended' => $enable + ? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.' + : 'Schaltet die automatische Update-Periodik ab.', + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; } - /** - * 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 + /** The EXACT root command(s) the toggle will run — identical to what apply() executes. */ + public function preview(Server $server, string $action, bool $enable): string { - $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, - ]; + if ($action === 'firewall') { + return $enable ? $this->firewall->enablePreview($server) : 'ufw disable'; } - // 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, - ]; + return $this->commandFor($action, $enable); + } + + /** @return array{ok: bool, output: string, preview: 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]; } - $res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no')); + // 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.']; + } + if (! $this->hasAuthorizedKey($server)) { + return ['ok' => false, 'preview' => $preview, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).']; + } + } + + // apt operations need a long timeout; the 12s default would abort the install. + $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]; } - /** Install + enable fail2ban. */ - private function applyFail2ban(Server $server): array + /** Single source of truth for the shell command of a (non-firewall) toggle. */ + private function commandFor(string $action, bool $enable): string { - $preview = $this->preview($server, 'fail2ban'); - $cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban'; - // apt download+install can take a minute — long timeout (12s default kills it). - $res = $this->fleet->runPrivileged($server, $cmd, 600); - - return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; + return match ($action) { + 'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')), + 'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')), + 'fail2ban' => $enable + ? '(dpkg -l fail2ban 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban) && systemctl enable --now fail2ban' + : 'systemctl disable --now fail2ban', + 'unattended' => $enable + ? '(dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades)' + .' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN + .' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer' + : 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN + .' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)', + default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + }; } - /** 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'; - // apt download+install can take a minute — long timeout (12s default kills it). - $res = $this->fleet->runPrivileged($server, $cmd, 600); - - 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. - */ + /** True if the SSH user has at least one authorized key. */ private function hasAuthorizedKey(Server $server): bool { try { @@ -160,26 +200,14 @@ class HardeningService // fall through to the raw check } - $res = $this->fleet->runPlain( - $server, - 'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0' - ); + $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). + * Install one sshd directive into the Clusev drop-in (replacing any prior value + * of the same key), then reload whichever ssh unit exists. */ private function sshDropInScript(string $directive): string { @@ -187,7 +215,6 @@ class HardeningService 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 diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 6ece49c..e0c299e 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -165,41 +165,24 @@
- @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', - 'border-warning' => $check['status'] === 'warning', - 'border-offline' => $check['status'] === 'offline', + 'border-online' => $check['secure'], + 'border-warning' => ! $check['secure'], ])>

{{ $check['label'] }}

{{ $check['detail'] }}

- @if ($check['status'] !== 'online' && $action) - - Anwenden +
+ {{ $check['secure'] ? 'OK' : 'Offen' }} + {{-- Bidirectional toggle: Aktivieren when the feature is off, Deaktivieren when on. --}} + + {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} - @else - - {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} - - @endif +
@endforeach