clusev/app/Services/HardeningService.php

197 lines
8.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use InvalidArgumentException;
/**
* Applies the dashboard server-hardening checklist items over SSH (as root).
*
* 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}.
*/
class HardeningService
{
/** Drop-in that holds every Clusev-managed sshd override. */
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf';
public function __construct(private FleetService $fleet) {}
/** 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}"),
};
}
/**
* 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)';
}
}