197 lines
8.4 KiB
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)';
|
|
}
|
|
}
|