214 lines
11 KiB
PHP
214 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Server;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* apply() runs the exact command built by commandFor() (the single source of truth);
|
|
* title()/description() supply the German confirm-modal copy for each direction.
|
|
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
|
|
*/
|
|
class HardeningService
|
|
{
|
|
// 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';
|
|
|
|
// 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';
|
|
|
|
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
|
|
|
|
/**
|
|
* 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<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
|
|
*/
|
|
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<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}> */
|
|
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' => $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}"),
|
|
};
|
|
}
|
|
|
|
public function description(string $action, bool $enable): string
|
|
{
|
|
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}"),
|
|
};
|
|
}
|
|
|
|
/** @return array{ok: bool, output: string} */
|
|
public function apply(Server $server, string $action, bool $enable): array
|
|
{
|
|
if ($action === 'firewall') {
|
|
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
|
|
|
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, '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, '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']];
|
|
}
|
|
|
|
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
|
private function commandFor(string $action, bool $enable): string
|
|
{
|
|
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}"),
|
|
};
|
|
}
|
|
|
|
/** True if the SSH user has at least one authorized key. */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$key = strtok($directive, ' ');
|
|
|
|
return 'mkdir -p /etc/ssh/sshd_config.d'
|
|
.' && touch '.self::SSHD_DROPIN
|
|
.' && 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)';
|
|
}
|
|
}
|