diff --git a/deploy/fail2ban/novacpx-admin.conf b/deploy/fail2ban/novacpx-admin.conf new file mode 100644 index 0000000..4d4f69a --- /dev/null +++ b/deploy/fail2ban/novacpx-admin.conf @@ -0,0 +1,5 @@ +[Definition] +# NovaCPX panel failed login attempts +# Format written by auth.php: "FAILED LOGIN from [portal]" +failregex = ^.+ FAILED LOGIN from +ignoreregex = diff --git a/deploy/fail2ban/novacpx-reseller.conf b/deploy/fail2ban/novacpx-reseller.conf new file mode 100644 index 0000000..4d4f69a --- /dev/null +++ b/deploy/fail2ban/novacpx-reseller.conf @@ -0,0 +1,5 @@ +[Definition] +# NovaCPX panel failed login attempts +# Format written by auth.php: "FAILED LOGIN from [portal]" +failregex = ^.+ FAILED LOGIN from +ignoreregex = diff --git a/deploy/fail2ban/novacpx-user.conf b/deploy/fail2ban/novacpx-user.conf new file mode 100644 index 0000000..4d4f69a --- /dev/null +++ b/deploy/fail2ban/novacpx-user.conf @@ -0,0 +1,5 @@ +[Definition] +# NovaCPX panel failed login attempts +# Format written by auth.php: "FAILED LOGIN from [portal]" +failregex = ^.+ FAILED LOGIN from +ignoreregex = diff --git a/deploy/fail2ban/novacpx-webmail.conf b/deploy/fail2ban/novacpx-webmail.conf new file mode 100644 index 0000000..4d4f69a --- /dev/null +++ b/deploy/fail2ban/novacpx-webmail.conf @@ -0,0 +1,5 @@ +[Definition] +# NovaCPX panel failed login attempts +# Format written by auth.php: "FAILED LOGIN from [portal]" +failregex = ^.+ FAILED LOGIN from +ignoreregex = diff --git a/install.sh b/install.sh index c02ea7e..1f583d4 100644 --- a/install.sh +++ b/install.sh @@ -547,11 +547,30 @@ log "Firewall configured" # ── Fail2Ban ───────────────────────────────────────────────────────────────── step "Configuring Fail2Ban" + +# Auto-detect local IPs to whitelist (loopback + all private interface IPs + their /24 subnets) +LOCAL_IPS="127.0.0.0/8 ::1" +while read -r cidr; do + ip="${cidr%%/*}" + LOCAL_IPS="$LOCAL_IPS $ip" + # Add /24 subnet for private ranges + case "$ip" in + 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) + subnet=$(echo "$ip" | awk -F. '{print $1"."$2"."$3".0/24"}') + LOCAL_IPS="$LOCAL_IPS $subnet" + ;; + esac +done < <(ip -4 addr show 2>/dev/null | grep 'inet ' | awk '{print $2}') +# Deduplicate +LOCAL_IPS=$(echo "$LOCAL_IPS" | tr ' ' '\n' | sort -u | tr '\n' ' ') +log "Fail2Ban whitelist: $LOCAL_IPS" + cat > /etc/fail2ban/jail.local </dev/null || \ + cat > /etc/fail2ban/filter.d/${jail}.conf << 'FILTER' +[Definition] +failregex = ^.+ FAILED LOGIN from +ignoreregex = +FILTER +done + +# Create NovaCPX access log writable by www-data +mkdir -p /var/log/novacpx +touch /var/log/novacpx/access.log +chown www-data:www-data /var/log/novacpx/access.log +chmod 664 /var/log/novacpx/access.log + systemctl enable fail2ban >> "$LOG" 2>&1 systemctl restart fail2ban >> "$LOG" 2>&1 log "Fail2Ban configured" diff --git a/panel/api/endpoints/auth.php b/panel/api/endpoints/auth.php index a1a7f3e..981dd5d 100644 --- a/panel/api/endpoints/auth.php +++ b/panel/api/endpoints/auth.php @@ -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([ diff --git a/panel/api/endpoints/firewall.php b/panel/api/endpoints/firewall.php index 58bfef5..925cd3f 100644 --- a/panel/api/endpoints/firewall.php +++ b/panel/api/endpoints/firewall.php @@ -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'] ?? ''); diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js index 9075b36..4203a23 100644 --- a/panel/public/assets/js/admin.js +++ b/panel/public/assets/js/admin.js @@ -725,15 +725,17 @@ // ── Firewall ─────────────────────────────────────────────────────────────── // ── Firewall ─────────────────────────────────────────────────────────────── async function firewall() { - const [fwRes, f2bRes, ipRes] = await Promise.all([ + const [fwRes, f2bRes, ipRes, ignoreipRes] = await Promise.all([ Nova.api('firewall','status'), Nova.api('firewall','f2b-status'), Nova.api('firewall','ip-lists'), + Nova.api('firewall','f2b-ignoreip-list'), ]); - const fw = fwRes?.data || {}; - const jails = f2bRes?.data?.jails || []; - const trusted = ipRes?.data?.trusted || []; - const blocked = ipRes?.data?.blocked || []; + const fw = fwRes?.data || {}; + const jails = f2bRes?.data?.jails || []; + const trusted = ipRes?.data?.trusted || []; + const blocked = ipRes?.data?.blocked || []; + const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || []; const rules = fw.rules || []; const active = fw.active; @@ -934,6 +936,29 @@ ` : `

Fail2Ban not running or no jails configured.

`} + +
+
+ Fail2Ban Whitelist + IPs that will never be banned + +
+
+
+ + +
+
+ ${(fwIgnoreips||[]).map(ip => fwIgnoreipChip(ip)).join('')} +
+

+ Loopback (127.0.0.0/8, ::1) and the server's own LAN IPs are added automatically. + Add your home/office IP or subnet here so you never lock yourself out. +

+
+
+
UFW Logging
@@ -1149,6 +1174,46 @@ ${ips.length ? ` Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : 'error'); }; + function fwIgnoreipChip(ip) { + const isLoopback = ip === '127.0.0.0/8' || ip === '127.0.0.1' || ip === '::1'; + return ` + ${Nova.escHtml(ip)}${isLoopback ? ' 🔒' : ' ×'} + `; + } + + window.fwIgnoreipAdd = async () => { + const ip = document.getElementById('fw-ignoreip-input')?.value?.trim(); + if (!ip) { Nova.toast('Enter an IP address or CIDR range', 'error'); return; } + const r = await Nova.api('firewall','f2b-ignoreip-add',{method:'POST',body:{ip}}); + Nova.toast(r?.message || (r?.success ? 'Added' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) { + const chips = document.getElementById('ignoreip-chips'); + if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join(''); + const inp = document.getElementById('fw-ignoreip-input'); + if (inp) inp.value = ''; + } + }; + + window.fwIgnoreipRemove = async (ip) => { + Nova.confirm(`Remove ${ip} from Fail2Ban whitelist? They could get banned if they fail too many login attempts.`, async () => { + const r = await Nova.api('firewall','f2b-ignoreip-remove',{method:'POST',body:{ip}}); + Nova.toast(r?.message || (r?.success ? 'Removed' : 'Failed'), r?.success ? 'success' : 'error'); + if (r?.success) { + const chips = document.getElementById('ignoreip-chips'); + if (chips) chips.innerHTML = (r.data?.ignoreip || []).map(fwIgnoreipChip).join(''); + } + }, true); + }; + + window.fwIgnoreipReset = () => { + Nova.confirm('Reset Fail2Ban whitelist to server defaults (loopback + local IPs)?', async () => { + const r = await Nova.api('firewall','f2b-ignoreip-reset',{method:'POST'}); + Nova.toast(r?.message || 'Reset', r?.success ? 'success' : 'error'); + if (r?.success) adminPage('firewall'); + }); + }; + // ── MySQL/DB Manager ─────────────────────────────────────────────────────── async function mysqlManager() { const res = await Nova.api('databases','list',{params:{account_id:0}});