433 lines
17 KiB
PHP
433 lines
17 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(__('backend.fail2ban_config_read_failed'));
|
|
}
|
|
|
|
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' => __('backend.invalid_input')];
|
|
}
|
|
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' => __('backend.invalid_input')];
|
|
}
|
|
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' => __('backend.ip_belongs_to_clusev')];
|
|
}
|
|
|
|
$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' => __('backend.invalid_ip_or_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' => __('backend.already_whitelisted')];
|
|
}
|
|
$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' => __('backend.loopback_stays_whitelisted')];
|
|
}
|
|
|
|
$cfg = $this->readConfig($server);
|
|
if (! in_array($ip, $cfg['ignoreip'], true)) {
|
|
return ['ok' => true, 'noop' => true, 'output' => __('backend.not_on_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
|
|
{
|
|
// Must START with an alphanumeric/underscore: a leading '-' would be parsed by
|
|
// fail2ban-client as an OPTION (e.g. "-h", "--help") rather than a jail argument,
|
|
// and a leading '.' has no legitimate jail use. Internal '.-_' stay allowed so real
|
|
// names like "nginx-http-auth" pass. (Shell metacharacters are already inert via the
|
|
// base64 transport in runPrivileged; this closes the argument-injection confusion.)
|
|
return (bool) preg_match('/^[A-Za-z0-9_][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);
|
|
}
|
|
}
|