'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'; // 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]; } /** 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. */ 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)'; } }