clusev/app/Services/Fail2banService.php

428 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use App\Support\Os\OsDetector;
use RuntimeException;
/**
* fail2ban management over SSH (as root): jail status + banned-IP list, unban,
* manual ban (lockout-guarded), and the Clusev-managed [DEFAULT] tuning + whitelist.
*
* Clusev owns EXACTLY ONE file — the zz-clusev.local drop-in — and rewrites the whole
* [DEFAULT] block on every change, so the tuning (bantime/findtime/maxretry) and the
* whitelist (ignoreip) can never clobber each other. Loopback is always whitelisted.
*/
class Fail2banService
{
// TWO 'zz-' drop-ins (sort LAST in jail.d so their [DEFAULT] wins). Tuning and the
// whitelist live in SEPARATE files and each writes only its OWN keys, so editing the
// whitelist can never rewrite (and clobber) the ban policy, and vice-versa.
private const DROPIN_TUNING = '/etc/fail2ban/jail.d/zz-clusev-tuning.local';
private const DROPIN_IGNOREIP = '/etc/fail2ban/jail.d/zz-clusev-ignoreip.local';
// The previous single-file drop-in (tuning only). It sorts AFTER the new split files,
// so it must be removed on migration or its stale [DEFAULT] would override new tuning.
private const LEGACY_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
// Always whitelisted so the operator can never lock fail2ban against localhost.
private const LOOPBACK = ['127.0.0.1/8', '::1'];
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
/** NULL when fail2ban is supported on this host, else a German reason. */
public function support(Server $server): ?string
{
return $this->detector->detect($server)->supports('fail2ban');
}
/**
* Jail status + banned IPs + whitelist in as few round-trips as possible.
*
* @return array<string, mixed>
*/
public function status(Server $server): array
{
$reason = $this->support($server);
$base = ['supported' => $reason === null, 'reason' => $reason, 'installed' => false, 'active' => false, 'jails' => [], 'ignoreip' => []];
if ($reason !== null) {
return $base;
}
$cmd = 'echo CLUSEV_F2B_OK; '
.'command -v fail2ban-client >/dev/null 2>&1 && echo INST=yes || echo INST=no; '
.'systemctl is-active --quiet fail2ban && echo ACT=active || echo ACT=inactive; '
// CLIENT_OK only when `fail2ban-client status` itself SUCCEEDS — so an active
// service whose client call fails is not misreported as "0 jails".
.'cs=$(fail2ban-client status 2>/dev/null) && echo CLIENT_OK; '
.'jails=$(printf "%s" "$cs" | sed -n "s/.*Jail list:[[:space:]]*//p" | tr "," " "); '
.'for j in $jails; do echo ===CLUSEV:jail:$j===; fail2ban-client status "$j" 2>/dev/null; done';
$res = $this->fleet->runPrivileged($server, $cmd);
if (! str_contains($res['output'], 'CLUSEV_F2B_OK')) {
return $base + ['readError' => true];
}
$out = $res['output'];
$installed = (bool) preg_match('/^INST=yes$/m', $out);
$active = (bool) preg_match('/^ACT=active$/m', $out);
// Service active but the client query failed -> a real read error, not "no jails".
if ($installed && $active && ! preg_match('/^CLIENT_OK$/m', $out)) {
return $base + ['readError' => true];
}
$jails = [];
foreach ($this->jailSections($out) as $name => $body) {
$jails[] = [
'name' => $name,
'currentlyFailed' => $this->intField($body, 'Currently failed'),
'totalFailed' => $this->intField($body, 'Total failed'),
'currentlyBanned' => $this->intField($body, 'Currently banned'),
'totalBanned' => $this->intField($body, 'Total banned'),
'bannedIps' => $this->bannedIps($body),
];
}
// ignoreip is managed separately (our drop-in); read it best-effort.
$ignoreip = [];
try {
$ignoreip = $this->readConfig($server)['ignoreip'];
} catch (RuntimeException) {
// leave empty on read failure
}
return ['supported' => true, 'reason' => null, 'installed' => $installed, 'active' => $active, 'jails' => $jails, 'ignoreip' => $ignoreip];
}
/**
* Read the effective [DEFAULT] tuning + whitelist (last value wins across files).
*
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
*/
public function readConfig(Server $server): array
{
// A leading sentinel proves the read RAN (sudo/SSH ok); a CLUSEV_RESET marker
// stops a section bleeding across file boundaries. Only [DEFAULT] keys count.
$cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf '
.'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do '
.'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null';
$res = $this->fleet->runPrivileged($server, $cmd);
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
}
return $this->parseDefaults($res['output']);
}
/**
* Write ONLY the [DEFAULT] tuning keys to the tuning drop-in (never ignoreip), so a
* tuning change can't touch the whitelist. Values must already be validated/clamped.
*
* @return array{ok: bool, output: string}
*/
public function writeTuning(Server $server, string $bantime, int $maxretry, string $findtime): array
{
$bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
$findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');
$content = "# Managed by Clusev — fail2ban tuning. Do not edit by hand.\n"
."[DEFAULT]\n"
."bantime = {$bantime}\n"
."findtime = {$findtime}\n"
."maxretry = {$maxretry}\n";
// Drop the legacy single-file drop-in (tuning only) so it can't override this.
return $this->writeDropin($server, self::DROPIN_TUNING, $content, [self::LEGACY_DROPIN]);
}
/**
* Write ONLY the [DEFAULT] ignoreip to the whitelist drop-in (never tuning), so a
* whitelist change can't touch the ban policy. Loopback is always re-seeded.
*
* @param array<int, string> $ignoreip
* @return array{ok: bool, output: string}
*/
public function writeIgnoreip(Server $server, array $ignoreip): array
{
$ignoreip = $this->normalizeIgnoreip($ignoreip);
$content = "# Managed by Clusev — fail2ban whitelist. Do not edit by hand.\n"
."[DEFAULT]\n"
.'ignoreip = '.implode(' ', $ignoreip)."\n";
return $this->writeDropin($server, self::DROPIN_IGNOREIP, $content);
}
/**
* Atomically write one drop-in (base64-safe), optionally removing stale files first,
* then reload fail2ban if it is running.
*
* @param array<int, string> $removeFirst
*/
private function writeDropin(Server $server, string $path, string $content, array $removeFirst = []): array
{
$rm = '';
foreach ($removeFirst as $stale) {
$rm .= 'rm -f '.$stale.'; ';
}
$b64 = base64_encode($content);
$cmd = 'mkdir -p /etc/fail2ban/jail.d; '.$rm
.'printf %s '.$b64.' | base64 -d > '.$path
.' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban || fail2ban-client reload; fi';
$res = $this->fleet->runPrivileged($server, $cmd, 120);
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
}
/** Unban one IP from a jail. "is not banned" counts as success (idempotent). */
public function unban(Server $server, string $jail, string $ip): array
{
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
}
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' unbanip '.$ip);
$ok = $res['ok'] || stripos($res['output'], 'not banned') !== false;
return ['ok' => $ok, 'output' => $this->tail($res['output'])];
}
/** Manually ban an IP in a jail. GUARDED against banning Clusev's own connection. */
public function ban(Server $server, string $jail, string $ip): array
{
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
}
// LOCKOUT GUARD: never ban loopback (the WHOLE 127.0.0.0/8 range + ::1) or the
// address Clusev connects FROM. Compare CANONICALLY (inet_pton) so an alternate
// IPv6 spelling cannot slip past.
$cp = $this->controlPlaneIp($server);
$loopback = $this->sameIp($ip, '::1')
|| (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).'];
}
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
}
/** Add an IP/CIDR to the whitelist (ignoreip), preserving the tuning values. */
public function addIgnoreIp(Server $server, string $ip): array
{
$ip = trim($ip);
if (! $this->validIpOrCidr($ip)) {
return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.'];
}
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
}
$cfg = $this->readConfig($server);
if (in_array($ip, $cfg['ignoreip'], true)) {
return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.'];
}
$list = $cfg['ignoreip'];
$list[] = $ip;
return $this->writeIgnoreip($server, $list);
}
/** Remove an IP/CIDR from the whitelist (loopback can never be removed). */
public function removeIgnoreIp(Server $server, string $ip): array
{
$ip = trim($ip);
if ($reason = $this->support($server)) {
return ['ok' => false, 'output' => $reason];
}
if (in_array($ip, self::LOOPBACK, true)) {
return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.'];
}
$cfg = $this->readConfig($server);
if (! in_array($ip, $cfg['ignoreip'], true)) {
return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.'];
}
$list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));
return $this->writeIgnoreip($server, $list);
}
/** Canonical IP equality (normalizes IPv6 spellings via inet_pton). */
private function sameIp(string $a, string $b): bool
{
$pa = @inet_pton($a);
$pb = @inet_pton($b);
return $pa !== false && $pb !== false && $pa === $pb;
}
/** Clusev's source IP as the server sees it (from $SSH_CONNECTION), or null. */
private function controlPlaneIp(Server $server): ?string
{
$res = $this->fleet->runPlain($server, 'printf %s "$SSH_CONNECTION"');
$ip = $res['ok'] ? (preg_split('/\s+/', trim($res['output']))[0] ?? '') : '';
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
}
/**
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
*/
private function parseDefaults(string $body): array
{
$vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m', 'ignoreip' => self::LOOPBACK];
$inDefault = false;
$cont = false; // currently inside an ignoreip continuation
$ignoreRaw = null; // raw ignoreip value (across continuation lines), last file wins
foreach (preg_split('/\R/', $body) ?: [] as $raw) {
$t = trim($raw);
if ($t === '' || $t[0] === '#' || $t[0] === ';') {
$cont = false;
continue;
}
// Section header (incl. the [CLUSEV_RESET] between files) resets state.
if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
$inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
$cont = false;
continue;
}
if (! $inDefault) {
$cont = false;
continue;
}
// INI continuation: an indented line right after ignoreip extends its value.
if ($cont && preg_match('/^\s/', $raw)) {
$ignoreRaw .= ' '.$t;
continue;
}
$cont = false;
if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) {
$vals['bantime'] = $m[1];
} elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) {
$vals['findtime'] = $m[1];
} elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) {
$vals['maxretry'] = (int) $m[1];
} elseif (preg_match('/^ignoreip\s*=\s*(.*)$/i', $t, $m)) {
$ignoreRaw = $m[1]; // last assignment (highest-precedence file) wins
$cont = true;
}
}
if ($ignoreRaw !== null) {
$vals['ignoreip'] = $this->normalizeIgnoreip(preg_split('/\s+/', trim($ignoreRaw)) ?: []);
}
return $vals;
}
/**
* Loopback first, then every existing entry VERBATIM (deduped) — hostnames, CIDRs
* and other fail2ban-valid tokens are preserved, never dropped (new entries from
* the UI are validated separately before they reach this list).
*/
private function normalizeIgnoreip(array $list): array
{
$out = self::LOOPBACK;
foreach ($list as $entry) {
$entry = trim((string) $entry);
if ($entry !== '' && ! in_array($entry, $out, true)) {
$out[] = $entry;
}
}
return $out;
}
/** @return array<string, string> jail name => status body */
private function jailSections(string $out): array
{
$sections = [];
$current = null;
$buffer = [];
foreach (preg_split('/\R/', $out) ?: [] as $line) {
if (str_starts_with($line, '===CLUSEV:jail:')) {
if ($current !== null) {
$sections[$current] = implode("\n", $buffer);
}
$current = rtrim(substr($line, strlen('===CLUSEV:jail:')), '=');
$buffer = [];
} elseif ($current !== null) {
$buffer[] = $line;
}
}
if ($current !== null) {
$sections[$current] = implode("\n", $buffer);
}
return $sections;
}
private function intField(string $body, string $label): int
{
return preg_match('/'.preg_quote($label, '/').':\s*(\d+)/i', $body, $m) ? (int) $m[1] : 0;
}
/** @return array<int, string> */
private function bannedIps(string $body): array
{
if (! preg_match('/Banned IP list:\s*(.*)$/im', $body, $m)) {
return [];
}
return array_values(array_filter(
preg_split('/\s+/', trim($m[1])) ?: [],
fn (string $ip): bool => (bool) filter_var($ip, FILTER_VALIDATE_IP)
));
}
private function validJail(string $jail): bool
{
return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail);
}
private function validIpOrCidr(string $v): bool
{
if (filter_var($v, FILTER_VALIDATE_IP)) {
return true;
}
if (! str_contains($v, '/')) {
return false;
}
[$ip, $mask] = explode('/', $v, 2);
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
return false;
}
$max = str_contains($ip, ':') ? 128 : 32;
return (int) $mask >= 0 && (int) $mask <= $max;
}
private function tail(string $out, int $max = 300): string
{
$out = trim($out);
return mb_strlen($out) <= $max ? $out : '…'.mb_substr($out, -$max);
}
}