detector->detect($server)->supports('fail2ban'); } /** * Jail status + banned IPs + whitelist in as few round-trips as possible. * * @return array */ 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} */ 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 $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 $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} */ 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 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 */ 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); } }