Fail2Ban whitelist management + auth failure logging

- firewall.php: auto-detect server IPs (loopback, all interface IPs,
  private /24 subnets) for Fail2Ban ignoreip; f2b-ignoreip-list/add/
  remove/reset actions; write to jail.local directly (www-data owns it);
  f2b_set_ignoreip() reloads fail2ban after every change
- auth.php: log failed logins to /var/log/novacpx/access.log in format
  fail2ban filters expect — "FAILED LOGIN from <IP> [portal]"
- deploy/fail2ban/: filter.d conf files for all 4 NovaCPX jails
- install.sh: auto-detect local IPs → ignoreip in jail.local; install
  filter files; create access.log (www-data:www-data 664)
- admin.js: Fail2Ban Whitelist section in firewall page — chip list with
  add/remove/reset; loopback shown with lock icon and non-removable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:10:05 +00:00
parent a0cd7d925e
commit 62707d62ce
8 changed files with 244 additions and 9 deletions
+10 -1
View File
@@ -9,7 +9,16 @@ match ($action) {
if (!$username || !$password) Response::error('Username and password required');
$auth = Auth::getInstance();
$token = $auth->attempt($username, $password);
if (!$token) Response::error('Invalid credentials', 401);
if (!$token) {
// Log failure for Fail2Ban to detect
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$port = (int)($_SERVER['SERVER_PORT'] ?? 0);
$portal = $port === PORT_ADMIN ? 'admin' : ($port === PORT_RESELLER ? 'reseller' : ($port === PORT_WEBMAIL ? 'webmail' : 'user'));
$logLine = date('Y-m-d H:i:s') . " FAILED LOGIN from {$ip} [{$portal}] user:{$username}\n";
@file_put_contents('/var/log/novacpx/access.log', $logLine, FILE_APPEND | LOCK_EX);
novacpx_log('warn', "Failed login for '$username' from $ip");
Response::error('Invalid credentials', 401);
}
$user = $auth->user();
audit('login', 'auth');
Response::success([
+103
View File
@@ -17,6 +17,61 @@ function fw_exec(string $cmd): string {
return trim($out ?: '');
}
define('JAIL_LOCAL', '/etc/fail2ban/jail.local');
/** Detect all local IPs for this server (loopback + all interface IPs + private subnets) */
function local_ips(): array {
$ips = ['127.0.0.0/8', '::1'];
$raw = shell_exec("ip -4 addr show 2>/dev/null | grep 'inet ' | awk '{print $2}'") ?: '';
foreach (array_filter(explode("\n", trim($raw))) as $cidr) {
$cidr = trim($cidr);
if (!$cidr) continue;
// Add the specific IP
$ip = explode('/', $cidr)[0];
if (!in_array($ip, $ips)) $ips[] = $ip;
// If it's a private range, add the /24 subnet too
if (preg_match('/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/', $ip)) {
$parts = explode('.', $ip);
$subnet = $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.0/24';
if (!in_array($subnet, $ips)) $ips[] = $subnet;
}
}
return $ips;
}
/** Read ignoreip list from jail.local [DEFAULT] */
function f2b_get_ignoreip(): array {
if (!file_exists(JAIL_LOCAL)) return local_ips();
$content = file_get_contents(JAIL_LOCAL);
if (preg_match('/^\s*ignoreip\s*=\s*(.+)$/m', $content, $m)) {
$raw = preg_replace('/\s+/', ' ', trim($m[1]));
return array_values(array_filter(explode(' ', $raw)));
}
return local_ips();
}
/** Write ignoreip list to jail.local and reload fail2ban */
function f2b_set_ignoreip(array $ips): void {
$ips = array_values(array_unique(array_filter($ips)));
$line = 'ignoreip = ' . implode(' ', $ips);
if (!file_exists(JAIL_LOCAL)) {
// Create jail.local with [DEFAULT] section
file_put_contents(JAIL_LOCAL, "[DEFAULT]\n{$line}\n\n[sshd]\nenabled = true\n");
} else {
$content = file_get_contents(JAIL_LOCAL);
if (preg_match('/^\s*ignoreip\s*=/m', $content)) {
$content = preg_replace('/^\s*ignoreip\s*=.+$/m', $line, $content);
} elseif (preg_match('/^\[DEFAULT\]/m', $content)) {
$content = preg_replace('/(\[DEFAULT\][^\[]*)/s', "$1{$line}\n", $content, 1);
} else {
$content = "[DEFAULT]\n{$line}\n\n" . $content;
}
file_put_contents(JAIL_LOCAL, $content);
}
shell_exec('sudo fail2ban-client reload 2>/dev/null');
}
/** Parse `ufw status verbose` into structured data */
function ufw_status(): array {
$raw = fw_exec('ufw status verbose');
@@ -317,6 +372,54 @@ switch ($action) {
Response::success(['output' => $out], 'Fail2Ban restarted');
break;
// ── Fail2Ban ignoreip: list ───────────────────────────────────────────
case 'f2b-ignoreip-list':
Response::success([
'ignoreip' => f2b_get_ignoreip(),
'detected' => local_ips(),
]);
break;
// ── Fail2Ban ignoreip: add ────────────────────────────────────────────
case 'f2b-ignoreip-add':
$ip = trim($body['ip'] ?? '');
if (!$ip) Response::error('ip required');
// Validate IP or CIDR
$base = explode('/', $ip)[0];
if (!filter_var($base, FILTER_VALIDATE_IP)) Response::error('Invalid IP or CIDR');
$list = f2b_get_ignoreip();
if (!in_array($ip, $list)) {
$list[] = $ip;
f2b_set_ignoreip($list);
}
audit('firewall.f2b-ignoreip-add', $ip);
novacpx_log('info', "Fail2Ban ignoreip added: $ip");
Response::success(['ignoreip' => f2b_get_ignoreip()], "$ip added to Fail2Ban whitelist");
break;
// ── Fail2Ban ignoreip: remove ─────────────────────────────────────────
case 'f2b-ignoreip-remove':
$ip = trim($body['ip'] ?? '');
if (!$ip) Response::error('ip required');
// Never remove loopback
if ($ip === '127.0.0.0/8' || $ip === '127.0.0.1' || $ip === '::1') {
Response::error('Cannot remove loopback address from whitelist');
}
$list = array_values(array_filter(f2b_get_ignoreip(), fn($i) => $i !== $ip));
f2b_set_ignoreip($list);
audit('firewall.f2b-ignoreip-remove', $ip);
novacpx_log('info', "Fail2Ban ignoreip removed: $ip");
Response::success(['ignoreip' => f2b_get_ignoreip()], "$ip removed from Fail2Ban whitelist");
break;
// ── Fail2Ban ignoreip: reset to server defaults ────────────────────────
case 'f2b-ignoreip-reset':
$defaults = local_ips();
f2b_set_ignoreip($defaults);
audit('firewall.f2b-ignoreip-reset', implode(' ', $defaults));
Response::success(['ignoreip' => $defaults], 'Whitelist reset to server defaults');
break;
// ── UFW: raw command (admin escape hatch) ─────────────────────────────
case 'raw':
$cmd = trim($body['cmd'] ?? '');